# Интеграции ТелеМакса

Всё, что происходит в проекте, складывается в одну ленту событий. Получатель либо
подписывается на неё вебхуком, либо читает сам по ключу. Обычно нужно и то и другое:
вебхук даёт скорость, лента — гарантию, что ничего не потерялось, пока приёмник лежал.

## Что настраивается в интерфейсе

Страница «Интеграции» в боковом меню:

- **Приёмник** — адрес, куда слать события, и маска типов («*», «monitoring.*»,
  список через запятую). Секрет генерируется при создании и виден в списке.
- **Ключ API** — для чтения ленты. Показывается один раз: в базе лежит только его хеш.
  Ключ можно отозвать (запись останется в списке) или удалить совсем.
- **Журнал доставок** — что ушло, с каким ответом и сколько попыток.
  Кнопка «Повторить» доступна для окончательно не доставленных.

## Вебхук

POST с телом JSON и заголовками:

| Заголовок | Значение |
|---|---|
| `X-Telemaks-Event` | тип события, например `monitoring.hit.created` |
| `X-Telemaks-Delivery` | идентификатор доставки, ключ идемпотентности |
| `X-Telemaks-Timestamp` | unix-время отправки |
| `X-Telemaks-Signature` | `t=<метка>,v1=<HMAC-SHA256>` |

Подпись считается от строки `<метка>.<тело>` на секрете приёмника:

```python
import hashlib, hmac

def check(secret: str, headers, body: bytes) -> bool:
    stamp = headers["X-Telemaks-Timestamp"]
    want = hmac.new(secret.encode(), stamp.encode() + b"." + body,
                    hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"t={stamp},v1={want}",
                               headers["X-Telemaks-Signature"])
```

Метка входит в подпись, чтобы перехваченный запрос нельзя было повторить позже:
отвергайте метки старше нескольких минут.

Успехом считается любой ответ 2xx. Иначе повтор через минуту, пять минут,
пятнадцать, час и шесть часов; после этого доставка помечается мёртвой и ждёт
человека. Отвечайте быстро — таймаут 10 секунд; тяжёлую работу делайте после ответа.

Одно и то же событие может прийти дважды (сеть оборвалась после вашего ответа) —
поэтому храните `delivery_id` и не обрабатывайте повторы.

## Лента

Адрес ТелеМакса — `https://tmaks.ru`, префикс API — `/api/v1`. Если принимающая
сторона просит «базовый адрес», это `https://tmaks.ru`; если полный адрес ленты, то
`https://tmaks.ru/api/v1/integrations/events`.

```
GET https://tmaks.ru/api/v1/integrations/events?since=<курсор>&type=monitoring.*&limit=100
X-API-Key: tmk_…
```

**Ответ — голый массив событий, без объекта-обёртки.** Пустая лента это `[]`, а не
`{"events": []}`:

```json
[
  {
    "id": "94b198d6-6a60-40b5-aecc-8208d61e758f",
    "type": "monitoring.hit.created",
    "created_at": "2026-09-15T19:56:49.172601Z",
    "cursor": "2026-09-15T19:56:49.172601Z/94b198d6-6a60-40b5-aecc-8208d61e758f",
    "data": { }
  }
]
```

Обратите внимание: элемент ленты и тело вебхука устроены **по-разному**. У вебхука есть
конверт доставки (`event`, `delivery_id`, `event_id`, `occurred_at`, `project_id`), у
элемента ленты — `id`, `type`, `created_at`, `cursor`. Общее у них — поле `data`: его
содержимое совпадает байт в байт, так что разбирать полезную нагрузку можно одной
функцией.

Параметры: `since` — курсор, `type` — фильтр по типу (поддерживает `monitoring.*`),
`rule` — только события одной фразы, `limit` — от 1 до 500, по умолчанию 100.

### Одна кампания, а не весь проект

Ключ выдаётся на проект, а фраз в проекте обычно несколько: рядом с акцией живут
постоянные темы. В общей ленте они перемешаны, и по одному тексту поста не всегда
понятно, чем он пойман. Поэтому:

```
GET /api/v1/integrations/rules
X-API-Key: tmk_…

[{"id": "0ae9a967-…", "query": "\"пирог на четвертое\"", "enabled": true,
  "hits_count": 15, "created_at": "2026-09-22T14:58:11Z"}]
```

и дальше `…/events?rule=0ae9a967-…`. Вместо `id` можно передать сам текст фразы —
сравнение без учёта регистра. Фильтр смотрит в событие, а не в список фраз, поэтому
работает и для удалённой фразы: события живут в ленте свои тридцать дней и после
её удаления, и без фильтра выглядят как чужие.

Порядок — по возрастанию времени. Сохраняйте `cursor` последнего обработанного события
и передавайте его в `since`: получите только то, что появилось после. Без `since`
отдаётся начало ленты. Признака «есть ли ещё» в ответе нет и не нужно: если вернулось
ровно `limit` записей, запрашивайте следующую страницу с курсором последней, и так пока
массив не станет короче `limit`.

Курсор непрозрачен — передавайте его обратно как есть, не разбирая на части. Метка
времени в нём заканчивается на `Z` именно для того, чтобы курсор можно было подставить
в адрес без кодирования. Вариант со смещением `+00:00` тоже принимается, в том числе
если плюс превратился в пробел при наивной склейке адреса.

Лента хранится 30 дней.

Уведомления в Telegram работают независимо: подключение приёмника их не отключает и не
заменяет — одно и то же попадание уходит и в телеграм, и в интеграцию.

## Цифры канала по ссылке

Отдельный способ, не связанный с мониторингом: даёте ссылку на канал, паблик, сайт или
пост — получаете подписчиков, прирост, просмотры и вовлечённость из каталога ТелеМакса.
Ключ тот же, что для ленты, лимиты запросов — по тарифу проекта.

Каналы из источников вашего проекта отдаются без ограничений. Цифры остальных каналов
идут по квоте аккаунта: на Free — 2 000 разных каналов за всё время, на Pro и Max — 3 000
в месяц. Считается каждый канал отдельно, в том числе внутри
списка; повторный запрос того же канала квоту не тратит. Сколько осталось, видно в
заголовках ответа `X-Channel-Quota-Limit` и `X-Channel-Quota-Left`.

```
GET https://tmaks.ru/api/v1/integrations/channel-stats?url=https://t.me/rian_ru
X-API-Key: tmk_…
```

Понимает ссылки Telegram (`t.me/ник`, `@ник`, ссылка на пост), ВКонтакте (паблик, страница,
пост `wall-…`), MAX (`max.ru/ник`), Одноклассников, Rutube (канал или ролик — берётся автор)
и адрес сайта. Ссылка на пост ведёт к каналу, который его опубликовал.

```json
{
  "url": "https://t.me/rian_ru",
  "found": true,
  "reason": null,
  "platform": "tg",
  "platform_name": "Telegram",
  "title": "РИА Новости",
  "handle": "rian_ru",
  "link": "https://t.me/rian_ru",
  "card": "https://tmaks.ru/c/tg/rian_ru",
  "subscribers": 3030098,
  "audience_kind": "subscribers",
  "visitors_month": null,
  "subscribers_at": "2026-10-17T18:46:55Z",
  "growth": {"day": -276, "week": -3715, "month": 18700},
  "views": {"avg": 200652, "median": 202002, "max_30d": 299214},
  "er": 0.83,
  "err": 6.62,
  "posts_30d": 1680,
  "last_post_at": "2026-10-17T16:04:19Z",
  "stats_at": null
}
```

| Поле | Что это |
|---|---|
| `subscribers` | подписчики; у сайта — посетители в сутки (`audience_kind: visitors_per_day`), за месяц — `visitors_month` |
| `growth` | прирост подписчиков за сутки, 7 и 30 дней; у сайта `null` |
| `views` | средние и медианные просмотры поста за 30 дней и самый просматриваемый пост |
| `er` | вовлечённость, %: реакции к просмотрам за 30 дней |
| `err` | охват, %: средние просмотры поста к подписчикам |
| `subscribers_at` | когда измерены подписчики |
| `stats_at` | у каналов, которые ТелеМакс не читает постоянно, — когда сборщик последний раз считал просмотры; у читаемых `null`, их цифры свежие всегда |
| `card` | карточка в каталоге; `null`, если канал есть в базе, но ещё не опубликован |

Пустое значение — цифры нет, а не ноль: например, у канала без постов за месяц нет просмотров.

Ответы без цифр — тоже `200`, с `"found": false` и причиной:

| `reason` | Что значит |
|---|---|
| `not_in_catalog` | канала пока нет в каталоге. ТелеМакс сразу начинает его читать и добавит в каталог — повторите запрос через несколько часов, обычно хватает суток. В сутки так добавляется до 50 новых каналов на проект |
| `unsupported` | площадку пока не читаем (YouTube, Дзен, Instagram и др., в `platform` — какая). Запрос записан: по таким запросам решаем, что подключать следующим |
| `unrecognized` | ссылку не удалось разобрать |
| `quota` | квота цифр каналов не из ваших источников исчерпана. Добавьте канал в источники проекта, перейдите на тариф больше или повторите запрос с `over_quota=true` |

Списком — до 100 ссылок за запрос, ответ в том же порядке:

```
POST https://tmaks.ru/api/v1/integrations/channel-stats
X-API-Key: tmk_…
Content-Type: application/json

{"urls": ["https://t.me/rian_ru", "https://vk.com/ria", "lenta.ru"]}
```

Цифры берутся из каталога, а не с площадки в момент запроса: подписчики обновляются
несколько раз в сутки, просмотры и ER — раз в сутки.

## Конверт события

```json
{
  "event": "monitoring.hit.created",
  "delivery_id": "e3b0c442-…",
  "event_id": "8f14e45f-…",
  "occurred_at": "2026-09-15T08:14:22.481Z",
  "project_id": "…",
  "cursor": "2026-09-15T08:14:22.481Z/8f14e45f-…",
  "data": { }
}
```

В `data` для `monitoring.hit.created`:

```json
{
  "hit": {"id": "…"},
  "rule": {"id": "…", "query": "#МыВместе2026"},
  "match": {"kind": "word", "query": "#МыВместе2026"},
  "source": {"platform": "max", "title": "Молодёжь Онлайн",
             "username": "molodezh", "url": "https://max.ru/molodezh"},
  "message": {"id": "…", "published_at": "2026-09-15T08:13:40Z",
              "link": "https://max.ru/molodezh/1841",
              "text": "…", "views": 12, "forwards": 34, "replies": 8,
              "cluster_id": "…",
              "media": [{"type": "photo", "url": "https://…jpg",
                         "thumbnail": null, "width": 1440, "height": 1814}]}
}
```

`platform` — `tg`, `max`, `vk`, `web`, `ok` (Одноклассники) или `rutube`.

`match` — как пост пойман (с 29.10.2026):

| `kind` | Что значит | `query` |
|---|---|---|
| `word` | по словам самой фразы | фраза |
| `sem` | по смыслу: слов фразы в посте может не быть | фраза |

Полезная нагрузка вложенная, и это не случайность: `rule` отвечает на вопрос «чем
поймано», `source` — «где вышло», `message` — «что именно». Разбирайте её как
`data.message.text`, а не как плоский объект; при наивном разборе все события
выглядят одинаково пустыми.

### Вложения

`message.media` — до четырёх вложений поста, в порядке публикации:

| Поле | Что это |
|---|---|
| `type` | `photo` или `video` |
| `url` | сам файл, если он у нас есть; иначе `null` |
| `thumbnail` | картинка-превью; у видео есть только она |
| `width`, `height`, `duration` | когда площадка их сообщает |

Адреса абсолютные и готовы к вставке в `<img>`. Откуда они ведут, зависит от
площадки: у ВКонтакте это их CDN, у MAX — обложка видео на okcdn, у телеграма —
кадр с нашего сервера (`tmaks.ru/media/…`) или с CDN телеграма (`cdn*.telesco.pe`),
у сайтов — картинка самого издания. У телеграма тип вложения не всегда известен:
кадр ролика может прийти как `photo`.
Файла видео нет ни у одной площадки: его не отдаёт никто, поэтому у видео
заполнено только `thumbnail`.

Пустой `media` бывает у закрытого канала телеграма: картинку такого поста
взять неоткуда, кроме самого телеграма.

Чужие CDN живут своей жизнью и однажды ссылку закроют. Если вложение нужно
надолго — в отчёте, в архиве кампании, — скачивайте его себе при получении
события, а не ходите по ссылке год спустя.

Массив пуст, когда вложений у поста нет или площадка их не отдала.

### Отклик

`views`, `forwards`, `replies` — просмотры, пересылки и комментарии. Отдаются те,
которые площадка сообщает: пересылки есть у телеграма и ВКонтакте, комментарии —
только у телеграма, у MAX и у сайтов ни того ни другого нет. Там, где величины
нет, стоит `null`, а не `0`: ноль означал бы «измерили и никто не переслал».

Цифры растут после публикации, поэтому в событии-находке они почти всегда
маленькие. Для отчёта берите событие `monitoring.hit.metrics` — оно приходит
с замерами через час и через сутки.

**Историю фразы лента отдаёт тоже.** Добавленная фраза сразу подтягивает прошлое —
на странице это видно как «найдено 15» в первую же минуту. Эти находки уходят в ленту
теми же событиями `monitoring.hit.created`, отдельной пометки у них нет: для отчёта по
кампании пост недельной давности ничем не хуже сегодняшнего. Разница только в
`created_at` события и `message.published_at`. Вглубь отдаётся до 2000 последних
находок на фразу — этого хватает на кампанию целиком; страница мониторинга при этом
знает всю историю и может показать больше.

`cluster_id` — справочное поле, и только. Каждый найденный пост приходит отдельным
событием: мы ничего не схлопываем и не считаем за одно, даже когда тексты совпадают.
Для инфокампании, где сообщества публикуют один и тот же текст с хештегом, это как раз
нужное поведение — принимающая сторона считает все посты. Поле можно просто игнорировать;
оно пригодится, если когда-нибудь понадобится обратное — отличить оригинал от перепечатки.

Для `monitoring.hit.metrics` добавляется:

```json
{"metrics": {"stage": "d1", "views": 4120, "views_total": 4380}}
```

`stage` — `h1` (замер через час после публикации) или `d1` (через сутки). Событие
приходит по тому же `hit.id`, что и находка: просмотры в момент обнаружения почти
всегда околонулевые, для отчёта по кампании нужны именно эти замеры. Метрики
досылаются только проектам, у которых есть приёмник или ключ.

## Типы событий

| Тип | Когда |
|---|---|
| `ping` | кнопка «Проверить» в интерфейсе |
| `monitoring.hit.created` | мониторинг нашёл упоминание — новое или в истории добавленной фразы |
| `monitoring.hit.metrics` | появились замеры просмотров по найденному посту |
| `monitoring.story.rising` | сюжет с фразой проекта (галочка «Сигнал о росте») или из его регионов и тем начал расти — одно событие на сюжет |

У `monitoring.story.rising` в `data`: `story` (`id`, `title`, `summary`, `url`,
`theme`, `region` — главные; `themes`, `regions` — все темы и места сюжета), `metrics` (`posts`, `posts_last_hour`, `channels`,
`new_channels_last_hour`, `platforms`, `regions`, `federal_channels`,
`first_published_at`, `score`) и `reasons` — за что пришло: `phrases`,
`region`, `theme`.

Новый тип добавляется в `EVENT_TYPES` (`project/services/integrations/events.py`)
и одним вызовом `emit()` в нужном месте — получатели и доставка уже есть.

## Про хештеги

Поиск идёт по словарю русского языка, и решётка при разборе отбрасывается: правило
`#МыВместе2026` поймает и пост, где написано `МыВместе2026` без решётки. Если для
кампании нужен строгий подсчёт именно хештегов, фильтруйте по тексту на своей
стороне — он приходит в `message.text`.

### Сверх квоты — за токены

Когда квота чужих каналов кончилась, добавьте к запросу `?over_quota=true` — и для GET, и для POST `/channel-stats`. Тогда каждый следующий канал спишет 1 000 токенов из месячного лимита тарифа: того же, что тратят рерайт и AI-редактор. Повторный запрос того же канала в этом месяце не списывает ничего. Сколько списано за запрос, — в заголовке `X-Tokens-Spent`. Без параметра токены не тратятся никогда.

