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

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

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

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

- **Приёмник** — адрес, куда слать события, и маска типов («*», «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 работают независимо: подключение приёмника их не отключает и не
заменяет — одно и то же попадание уходит и в телеграм, и в интеграцию.

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

```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"},
  "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`.

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

### Вложения

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

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

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

Чужие 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` | появились замеры просмотров по найденному посту |

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

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

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