---
tags:
  - type/plan
  - topic/vibe-coding
  - project/mars-bot
date: 2026-06-09
---

# mars-bot: упоминания каналов (дизайн)

Расширение существующего проекта `mars-bot` фичей: отслеживать публичные упоминания Mars-каналов и личного канала Наташи в Telegram, слать отбивку в нужный TG-чат.

Не отдельный проект — модуль рядом с CLI. Делит `.env`, `chats.json` и отправлялку через тот же Bot API.

Базовый MVP `mars-bot` (отправка саммари митингов): `docs/{vibe-coding} {plan} mars-bot mvp дизайн – 2026-06-05.md`. Заготовка под эту фичу была в секции «Заготовка под будущее (упоминания каналов)» MVP-документа.

## Цель и use cases

Наташе нужны уведомления, когда наш канал упоминают в публичном TG, чтобы:

1. **Сказать спасибо / ответить автору** (личный контакт, выстраивание партнёрств).
2. **Трекать охват и маркетинговую активность** (кто и как часто про нас пишет).
3. **Мониторить негатив / fake info** (репутация).

«Репостить упоминание в наш канал» — НЕ задача в этом MVP. Поэтому скорость не realtime — раз в 4 часа достаточно.

## Что считается упоминанием

Точное совпадение по одному из паттернов:

- `@<username>` — например `@marsingru`
- `t.me/<username>` — например `t.me/marsingru` (с любым протоколом и без)

Текстовые упоминания без ссылки («Школа Марс», «Mars», «Это Хор») **не отслеживаем** — слишком много ложных совпадений (марсиане, Mars-батончик, и т.д.).

## Tracked-каналы и роутинг

| Канал | username | Куда слать отбивку |
|---|---|---|
| Mars (основной) | `marsingru` | `backoffice` |
| Это Хор | `choooooooir` | `backoffice` |
| Творческие люди | `tvorcheskiye_lyudi` | `backoffice` |
| Начальник тоже человек (личный Наташи) | `natashhhh` | `test` (личка с ботом) |

«куда слать» — это алиас из `chats.json`. При резолве отбивки бот берёт `chat_id` по алиасу.

## Источник данных: TGStat API Stat (платный тариф)

Сервис уже используется в `projects/vibe-coding/tgstat-puller/` для подтягивания постов своих каналов. Для упоминаний — отдельный endpoint того же сервиса, **`GET /channels/mentions`**, специально под нашу задачу.

**Что делает endpoint.** По параметру `channelId=@username` возвращает посты из всех публичных каналов, где этот канал упоминается. TGStat сам матчит все три формы (`@username`, `t.me/username`, `t.me/username/12345`) — нам не надо собирать q-строки и склеивать результаты.

**Параметры:** `token`, `channelId` (обязательные); `startDate`, `endDate`, `limit` (max 50, default 20), `offset`, `extended` (опциональные).

**`extended=1`** возвращает в ответе ещё и объекты каналов-источников (название, кол-во подписчиков) — экономит дополнительные вызовы.

**Поля ответа per item:** `mentionId`, `mentionType` (`'channel'` или `'post'`), `postId`, `postLink`, `postDate` (unix ts), `channelId` (источник). **Текст поста endpoint НЕ отдаёт** — он только указывает на пост. Этого достаточно для нашей минимальной карточки.

**Тариф:** API Stat от ~1500 ₽/мес. Квота — порядка 400k запросов за биллинг-цикл, плюс лимит на уникальные каналы. Наш масштаб (4 канала × 1 вызов на канал × ~6 циклов/день ≈ 24 запроса/день) тратит мизер. Точный текущий прайс — на `tgstat.ru/p/prices`.

**Бесплатный тариф `channels/mentions` не покрывает** — endpoint доступен только на API Stat.

Альтернативы рассмотрены и отброшены:
- **Telethon userbot** — риск бана аккаунта, меньшее покрытие индексом TG, ограничения на global search.
- **Telegram Bot API** — у бота нет доступа к публичному поиску по другим чатам; только то, где он сам участник.
- **Гибрид TGStat + Telethon** — overengineering для MVP.

## Архитектура и расположение в коде

Новые модули рядом с существующими в репо `mars-bot/`:

```
src/mars_bot/
  cli.py              # есть (отправка саммари)
  config.py           # есть; расширяем — читать _mentions блок
  telegram.py         # есть; переиспользуем send_html
  format.py           # есть; для рендера карточек
  sent_log.py         # есть; ТОЛЬКО для cli.py send, отбивки mentions сюда не пишем
  bot.py              # НОВОЕ: точка входа one-shot прогона
  tgstat.py           # НОВОЕ: клиент channels/mentions
  mentions_db.py      # НОВОЕ: SQLite, INSERT OR IGNORE, alerted flag
  alert.py            # НОВОЕ: формат карточки

scripts/
  mars-bot            # есть
  mars-bot-mentions   # НОВОЕ: wrapper по аналогии (sys.path + import)

data/
  sent.log            # есть
  mentions.db         # НОВОЕ: SQLite, gitignored
```

Запуск — не daemon. Один прогон = `mars-bot-mentions check`. Расписание — systemd timer на Pi, каждые 4 часа.

## Конфиг

**`.env`** добавляется одна строка:
```
TGSTAT_TOKEN=<токен из api.tgstat.ru>
```

> Тот же токен, что в `tgstat-puller/.env`. После апгрейда тарифа API Stat у пользователя токен **не меняется** — endpoint'ы просто разблокируются. Можно скопировать значение или (если ставим mars-bot на Pi отдельно от tgstat-puller) поставить как переменную окружения через тот же путь.

**`chats.json`** получает новый блок `_mentions` рядом с алиасами. Префикс `_` чтобы CLI/getter алиасов мог отличить служебный блок от чатовых пар:

```json
{
  "test": 822794,
  "content": -1002061584409,
  "backoffice": -1001227818099,
  "events": -4947815489,
  "marketing": -4631691391,
  "b2b": -4849130868,
  "admins": -5187658593,
  "_mentions": {
    "tracked": {
      "marsingru": "backoffice",
      "choooooooir": "backoffice",
      "tvorcheskiye_lyudi": "backoffice",
      "natashhhh": "test"
    }
  }
}
```

**Доработка `config.py` (важно для совместимости).** Сейчас `config.py:43` валидирует **все значения `chats.json` как `int`**. Если просто положить `_mentions` в файл — обычный `cli.py send` упадёт с `ConfigError: chat_id for '_mentions' must be int, got dict`.

Исправление в `load_config`:
1. **До** валидации int-ов отфильтровать все ключи, начинающиеся с `_`, в отдельный словарь.
2. Чатовые пары `{alias: chat_id}` валидировать как сейчас.
3. Служебный блок `_mentions` отдать через отдельный `load_mentions_config()` → `{tracked: dict[str, str]}`. Резолв destination → chat_id идёт через тот же общий пул алиасов.

Это backward-compat изменение: новый `chats.json` с `_mentions` должен продолжать работать со старым `cli.py send` (отбивки саммари не ломаются). Под это — отдельный acceptance-тест (см. секцию «Тесты»).

## Данные и dedup

`data/mentions.db` (SQLite) с одной таблицей. Поля выровнены по ответу TGStat `channels/mentions`:

```sql
CREATE TABLE mentions (
  post_id            INTEGER PRIMARY KEY, -- TGStat postId (int64)
  mars_channel       TEXT NOT NULL,       -- какой из наших упомянули (marsingru / choooooooir / ...)
  mention_type       TEXT NOT NULL,       -- 'channel' или 'post' (из mentionType)
  source_channel_id  INTEGER,             -- TGStat channelId источника (int64)
  source_username    TEXT,                -- username из channels[] (приходит с '@', нормализуем при записи)
  source_title       TEXT,                -- title из channels[]
  source_subscribers INTEGER,             -- participants_count из channels[]
  posted_at          INTEGER NOT NULL,    -- postDate из TGStat (unix ts)
  found_at           INTEGER NOT NULL,    -- unix ts на момент обнаружения
  post_link          TEXT NOT NULL,       -- postLink
  alerted            INTEGER DEFAULT 0
);
CREATE INDEX idx_posted_at ON mentions(posted_at);
```

**Маппинг полей TGStat → DB** (подтверждено реальным ответом):
- `items[].postId` → `post_id`
- `items[].mentionType` → `mention_type`
- `items[].channelId` → `source_channel_id`
- `items[].postDate` → `posted_at`
- `items[].postLink` → `post_link`
- `channels[].username` (с `@`, например `"@mosptichka"`) → `source_username` (храним нормализованным: `lstrip('@').lower()`, например `"mosptichka"`)
- `channels[].title` → `source_title`
- `channels[].participants_count` → `source_subscribers`

Текст поста не храним — карточка минимальная, делать второй вызов `posts/get` per mention не стали (см. секцию «Формат карточки»).

**Dedup-логика:**
1. Пришёл пост от TGStat → `INSERT OR IGNORE` по `post_id`.
2. Если строка появилась (новый пост) — `alerted=0`, отбиваем в TG.
3. При успехе отправки — `UPDATE alerted=1`.

Разделение «увидели / отправили» позволяет восстановиться после сбоев Bot API: следующий прогон добивает строки с `alerted=0`.

**Self-mention фильтр.** Если `source_username` совпадает с одним из tracked — пост игнорируется. Иначе наш репост одного нашего канала в другой засчитается как «упоминание». Фильтр в TGStat-клиенте, до записи в БД.

Сравнение должно быть устойчивым к капитализации, к ведущему `@` и к `None`:
- Нормализация: `s.strip().lstrip('@').lower()` для `source_username` и для каждого ключа в `tracked`.
- Если `source_username` пуст / `None` (TGStat по каким-то причинам не отдал) — пост НЕ считаем self-mention (пропускаем фильтр и пишем строку как обычно). Лучше шумнуть в журнал и доставить отбивку, чем тихо потерять упоминание.

## Цикл проверки (один прогон `bot.py check`)

```
1. Загрузить .env + chats.json. Прочитать _mentions блок и TGSTAT_TOKEN.
   Если чего-то нет — exit 2 (config error).

2. Открыть data/mentions.db; если файла нет — создать схему inline.

3. PRE-PASS: добить недосланные.
   SELECT * FROM mentions WHERE alerted=0 ORDER BY found_at.
   Для каждой: попытаться отправить → alerted=1 при успехе.

4. MAIN: для каждого канала в tracked:

   - tgstat.get_mentions(channelId=f"@{username}", extended=1, limit=50)
   - пауза 0.3s между запросами (как в tgstat-puller)
   - из ответа достаём `items[]` (упоминания) и `channels[]` (объекты каналов-источников),
     по `channelId` мапим источник к названию/подписчикам
   - для каждого item:
       - skip, если source_username ∈ tracked (self-mention)
       - INSERT OR IGNORE по postId
       - если RowID получен (новый пост):
           - отправить отбивку
           - при успехе: UPDATE alerted=1

5. POST: лог в stdout (сколько проверили, новых, отправили, ошибок). exit 0.
```

**Cold start.** Первая инициализация БД (файл отсутствует → создаём схему) включает флаг «начальное наполнение»: первый прогон заполняет таблицу без отправки отбивок, все строки с `alerted=1`. Со второго прогона — нормальная работа. Защита от потопа: TGStat в `channels/mentions` без `startDate` отдаёт исторические упоминания (max 50 за вызов) — без cold-start фильтра пришлось бы выгребать всё сразу в чат.

**Лимит TGStat 50 на запрос.** `limit=50` — максимум, который endpoint отдаёт за один вызов. При нашем масштабе и cadence 4ч в один цикл точно укладываемся — не пагинируем. Если когда-нибудь упрёмся — добавим пагинацию через `offset` отдельным изменением (YAGNI).

**Rate-limit Telegram.** `sent_log.py` (20 сообщений/час) применяется ТОЛЬКО к саммари митингов из `cli.py send`. Mentions-отбивки идут отдельным стримом — их `bot.py` не логирует в `sent_log` и не учитывает в счётчике. Обоснование: трафик саммари и mentions смысловой и операционно разный; общая 20/час корзина приведёт к ложному троттлингу одного из-за другого. Если TG сам зарейтлимитит mentions при настоящем шторме упоминаний — `bot.py` поймает `TelegramError`, оставит `alerted=0`, доберёт в следующем цикле через 4 часа.

## Формат карточки

Markdown, рендерится наш `format.md_to_html`. Минимальная карточка — основные поля + ссылка. Текст поста не подтягиваем (нет в ответе `channels/mentions`, отдельный вызов `posts/get` не оправдан для нашего use case).

Первая строка — `#mention` (по аналогии с `#summary`). Все ссылки — в формате `[text](url)` без угловых скобок: наш md-конвертер `<url>` как обёртку URL не поддерживает (попадёт в href как литералы и сломает HTML).

Готовая карточка с подставленными значениями:

```markdown
#mention

📢 **Упоминание @marsingru**

**Канал:** [Радио для своих](https://t.me/radioforfriends) · 3.4k подписчиков
**Опубликовано:** 09.06.2026 14:32

[Открыть пост ↗](https://t.me/radioforfriends/12345)
```

(В шаблоне реальные значения подставляются как обычные строки, без `<>`.)

**Детали:**

- `#mention` без пробела (наш конвертер не засчитает за heading; в Telegram станет hashtag).
- `@<username>` в заголовке — какой из НАШИХ упомянули (не источник).
- **Один пост = одна карточка**, даже если автор упомянул в посте несколько наших каналов одновременно. Dedup по `post_id` (PK) → одна строка в БД, одна отбивка. Поведение при collision документировано в отдельной секции «Edge case: один пост — несколько наших каналов» (ниже).
- Подписчики — формат `3.4k` / `12k` / `987`. Если TGStat не отдал — пишем «—».

**Не кладём в карточку:** текст поста (нужен был бы доп. вызов `posts/get` на каждое упоминание — не оправдано), реакции/комменты (TGStat их не отдаёт в этих методах), сентимент (overkill для MVP), картинки/превью (ссылка покажет).

## Edge case: один пост — несколько наших каналов

Реальный сценарий: блогер запостил «школа марс @marsingru и Наташа Дудина @natashhhh — рекомендую обоих». Этот пост вернётся в ответах **двух** запросов: `channelId=@marsingru` и `channelId=@natashhhh`. В обоих случаях `postId` тот же.

**Поведение в MVP:**
1. Цикл проверки идёт по `tracked` в порядке ключей в `chats.json` (Python ≥3.7 гарантирует insertion order для dict).
2. Первый, чей запрос вернул этот `postId`, кладётся в БД с этим `mars_channel` → отбивка уходит в его destination.
3. Второй запрос уже находит запись с этим `postId` через `INSERT OR IGNORE` — пропускает.

**Эффект:** **порядок ключей в `tracked` определяет, куда уйдёт отбивка** при коллизии. В текущем конфиге `marsingru` стоит первым → такой пост уйдёт в `backoffice`, не в `test`. Если изменить порядок (поставить `natashhhh` первым) — поменяется поведение.

**Это явно не симметрично и не оптимально**, но осознанно out-of-scope MVP. Будет неудобно в реальной работе — переходим на composite PK `(post_id, mars_channel)`: одна строка на каждое сочетание → две отбивки в обе destination. Это не блокер на старте.

## Деплой на Pi

По образцу `tasks-bot` и `conference-bot`:

```
/home/pi/mars-bot/                       # git clone
  .venv/                                 # python3.11
  .env                                   # с TGSTAT_TOKEN
  chats.json                             # с _mentions блоком
  data/mentions.db                       # SQLite, persistent

/etc/systemd/system/
  mars-bot-mentions.service              # oneshot service
  mars-bot-mentions.timer                # каждые 4 часа
```

**Service:**
```ini
[Unit]
Description=mars-bot mentions watcher (one-shot)

[Service]
Type=oneshot
WorkingDirectory=/home/pi/mars-bot
ExecStart=/home/pi/mars-bot/.venv/bin/python scripts/mars-bot-mentions check
User=pi
```

**Timer:**
```ini
[Unit]
Description=Run mars-bot mentions watcher every 4 hours

[Timer]
OnBootSec=5min
OnUnitActiveSec=4h
RandomizedDelaySec=5min
Persistent=true

[Install]
WantedBy=timers.target
```

`Persistent=true` догоняет пропущенный запуск после ребута. Логи через journald.

**Smoke-test перед деплоем:**
`.venv/bin/python scripts/mars-bot-mentions check --dry-run` — печатает карточки в stdout. **Не пишет в БД, не шлёт в TG.** То есть после `--dry-run` следующий нормальный запуск увидит те же упоминания и пошлёт по ним отбивки заново — это правильное поведение: dry-run не должен иметь побочных эффектов на реальное состояние.

## Ошибки и поведение

| Что упало | Действие |
|---|---|
| TGStat API 4xx (auth) | exit 2, journald покажет |
| TGStat API 5xx / network | log warning, skip канал, продолжить остальные |
| Bot API 4xx (бот выгнан) | log error, оставить `alerted=0`, ретрай в след. цикле |
| Bot API 5xx | то же |
| Битый ответ TGStat (нет post_id и т.п.) | skip пост, log warning |
| SQLite locked / corrupted | exit 6, разбираться руками |

Не делаем:
- Не алертим при «TGStat не отвечает 3 дня». Если понадобится — добавим heartbeat-канал.
- Не делаем retry внутри одного прогона. Естественный retry — следующий запуск через 4 часа.

## Тесты (offline, моки)

- `test_old_cli_still_works_with_mentions_block` — `chats.json` с блоком `_mentions` загружается через `load_config()` без падения; `cli.py send --to test` отправляет нормально (`_mentions` не валидируется как chat_id)
- `test_tgstat_parser` — валидный и битый ответ `channels/mentions`; поля `items[]`, `channels[]`, `mentionId`, `postId`, `postLink`, `postDate`, `channelId`
- `test_mentions_db` — INSERT OR IGNORE, alerted flag transitions, миграция при пустом каталоге
- `test_self_mention_filter` — `@marsingru`, `marsingru`, `MarsInGru`, `  @MarsInGru  ` все распознаются как self-mention; `None` / пустая строка — не self-mention (пишем и шлём)
- `test_cold_start` — первый прогон с пустой БД заполняет, но не отправляет (все строки `alerted=1`)
- `test_card_format` — golden test rendering карточки; ссылки в форме `[text](url)` без угловых скобок
- `test_send_failure_keeps_alerted_zero` — Bot API 500 → строка осталась `alerted=0`, второй прогон переотправил
- `test_dry_run_has_no_side_effects` — мок TGStat вернул 3 поста, `--dry-run` печатает 3 карточки в stdout, **БД пустая**, Bot API не вызывался
- `test_multi_channel_collision_order_wins` — пост, упоминающий и `marsingru`, и `natashhhh`; при порядке `tracked` `[marsingru, natashhhh]` отбивка уходит в destination `marsingru`; обратный порядок — другой результат

Все тесты offline, <1s, как в текущем mars-bot (71 тест).

## Pre-implementation verification (обязательный шаг)

До написания кода — один реальный вызов TGStat с оплаченным токеном, **только чтобы подтвердить имена полей в ответе.** Документация и реальный JSON иногда расходятся (особенно у TGStat), и поправить парсер легче по живому ответу, чем разруливать падения тестов потом.

```bash
curl -s "https://api.tgstat.ru/channels/mentions?token=$TGSTAT_TOKEN&channelId=@marsingru&extended=1&limit=3" | jq
```

**Что проверяем в ответе:**
- `status == "ok"`, есть `response.items[]`
- В каждом item: **`postId`** (int64), **`postLink`** (str), **`postDate`** (unix ts), **`channelId`** (int64), `mentionId`, `mentionType` (`'channel'`/`'post'`)
- При `extended=1` есть `response.channels[]` с `id` (mapping к `channelId` items), `username` (с ведущим `@`), `title`, `participants_count`

✅ Pre-impl verification выполнена 2026-06-09 на токене из `tgstat-puller/.env` — все имена/типы сошлись. Сэмпл ответа: упоминания от `@mosptichka` (514k), `@polinamax_actress` (6.3k), `@golovina_das` (45). Реальный JSON соответствует доке. Маппинг полей в DB зафиксирован в секции «Данные и dedup».

## Acceptance критерии MVP

- **`cli.py send` (старая фича) НЕ ломается** после добавления блока `_mentions` в `chats.json` — отдельный тест `test_old_cli_still_works_with_mentions_block` проходит
- Pre-implementation verification пройдена: имена полей в реальном ответе TGStat сошлись со спекой (либо спека/клиент скорректированы)
- `bot.py check` запускается, читает `_mentions` блок и `TGSTAT_TOKEN`, не падает при пустых результатах
- `mentions.db` создаётся при отсутствии, миграция inline
- При cold start (новая БД) первый прогон **не отправляет** отбивок; со второго работает нормально
- Self-mention фильтр устойчив к регистру и `@`-префиксу
- `--dry-run` не имеет побочных эффектов (БД пустая, TG не вызван)
- Per-channel routing работает: упоминание `marsingru` → `backoffice`, упоминание `natashhhh` → `test`
- Поведение collision при упоминании нескольких наших каналов в одном посте задокументировано и покрыто тестом
- Все тесты зелёные
- Smoke-test на реальном TGStat: получили хоть одну отбивку в реальный чат
- Деплой на Pi через systemd timer, журнал виден через `journalctl -u mars-bot-mentions`

## Что НЕ в MVP (out of scope)

- Аналитический отчёт «топ-N каналов, упомянувших нас за период» — данные копятся, отчёт допишем когда понадобится
- Сентимент-анализ упоминаний (positive/negative/neutral)
- Telethon как fallback при недоступности TGStat
- Текстовые упоминания без ссылки («Школа Марс», «Mars», «Это Хор»)
- Алерты в личку отдельно от группового чата (можно дублировать через два алиаса в `tracked`, если когда-нибудь понадобится)
- Текст поста в карточке (нужен был бы доп. вызов `posts/get` per mention; добавим если станет неудобно ходить по ссылкам)
- Аналитика реакций и комментариев под упоминающим постом (TGStat `channels/mentions` их не отдаёт)
- Composite PK `(post_id, mars_channel)` для постов, упоминающих несколько наших каналов одновременно. Поведение MVP документировано в секции «Edge case: один пост — несколько наших каналов»; переход на composite PK — лёгкий, делается отдельным изменением, когда edge case станет реально неудобным
