Skip to main content
Glama
WhiteBite

tg-agent (Telegram → SQLite → MCP)

by WhiteBite
README.md
# Telegram-группы и темы для агента в opencode

![tests](https://github.com/WhiteBite/tg-agent/actions/workflows/ci.yml/badge.svg)

Read-only связка: **Telethon (твой аккаунт) → локальный SQLite → MCP-сервер → агент**.

Главная идея — **агент видит не всё подряд, а только то, что ты отметил сам**.
Тем может быть десятки, в каналах — годы переписки; вываливать это в контекст
бессмысленно. Поэтому:

1. ты выделяешь нужное (папка в Telegram **или** точечно темы через `watch.py`),
2. это синхронизируется в локальный индекс (инкрементально, старая история — чанками),
3. агент получает 13 read-only инструментов, которые знают твои алиасы и «что нового с прошлого раза».

---

---

## Быстрый старт: одна команда

```bash
python3 start.py
```

Она сама решает, что нужно: нет ключей — спросит и запомнит; не залогинен —
покажет QR; дальше открывает визуал в браузере, где всё остальное мышкой:
**「+ из Telegram」** — список чатов прямо из Telegram (форум/группа/канал,
пометка «в индексе»), фильтр, окно дней и кнопка «Слить»; **★** — отдать тему
агенту. Ни флагов, ни имён чатов, ни запоминания команд.

Всё описанное ниже — те же шаги, но вручную, для тех, кому нужен точный контроль
(в терминале есть и запасной выбор чатов: `python3 start.py --cli`).

## Как «визуально выделить нужное»

### Вариант 0 — веб-визуал «как в тг» (рекомендую)

```bash
python3 ui.py                 # http://127.0.0.1:8899
```

Три колонки в тёмной теме Telegram Desktop: **чаты → темы → сообщения**.
Рейка слева фильтрует: все / ★ мои / форумы / каналы. Что умеет:

- звёздочка на теме — отметить для агента (или «★ отметить все темы» на чат);
- алиас на отмеченную тему — имя, которое агент увидит в `tg_watch`;
- синие бейджи — непрочитанное с момента «✓ прочитано»;
- поиск по чатам и **FTS-поиск по всей истории** прямо в панели сообщений;
- 📁 рейка показывает твои папки Telegram — фильтр по составу папки;
- 📌 баннер закреплённых сообщений темы («readme») над лентой;
- 📝 баннер дайджеста, если он есть для темы (см. «Дайджесты»);
- поле «к дате» — прыжок к истории за любое число (полезно для летних архивов);
- выделение сообщений → «копировать для агента» — вставляет в opencode разом;
- **live**: при запущенном `live.py` лента сама догоняет новые сообщения;
- кнопка ⟳ — `sync.py --watched` в фоне (нужны креды; статус виден под поиском).

Выбор пишется в ту же таблицу `watch`, которую читает MCP-сервер, — агент в
opencode видит отметки сразу, без перегенерации конфигов. `watch.py` остаётся
для терминала и скриптов.

Посмотреть без Telegram: `python3 seed_demo.py demo.sqlite && TG_DB=demo.sqlite python3 ui.py`.

### Вариант A — папка в Telegram

В приложении: **Settings → Folders → New folder**, назови например `AI Agent`,
положи туда нужные каналы и группы. Отмечать в коде ничего не нужно — что в
папке, то и читает агент:

```bash
python3 sync.py --folder <id папки>     # id видно в веб-версии Telegram в URL папки
sync.py --dry-run --folder 7            # сначала посмотреть, что попадёт
```

Каналы-вещания из папки берутся автоматически (раз положил — значит нужно).
Пополнять папку можно и без приложения — агенту достаточно попросить:

```bash
python3 folders.py list                                  # папки и их состав
python3 folders.py add --folder 15 --chat @ai_channel    # доложить чат в папку
python3 folders.py remove --folder 15 --chat @ai_channel
```

### Вариант B — точечно темы внутри одной группы

Когда группа одна (твоя `AI`), а тем в ней десятки:

```bash
python3 watch.py topics --chat @mygroup      # темы с цифрами: сколько сообщений, закрыта ли
python3 watch.py add --chat @mygroup --topic 123456 --alias "ML-новости" --pin
python3 watch.py add --chat @mygroup --topic 789   --alias "Релизы"
python3 watch.py list                        # что отмечено и сколько там непрочитанного
python3 watch.py suggest                     # топ-тем по активности + готовые команды add
python3 sync.py --watched                    # синхронизировать только отмеченное
```

Алиас — это то, что увидит агент вместо `chat_id=-1001234567890 / topic_id=789`.

Оба варианта совмещаются: папка даёт каналы, `watch.py` — темы внутри них.

---

## Инструменты агента (все read-only)

| Инструмент | Что делает |
|---|---|
| `tg_watch` | **Первым делом.** Список отмеченного: алиас, название темы, сколько всего сообщений, сколько новых, дата последнего |
| `tg_new` | Новые сообщения темы с момента прошлого прочтения. Основной для «что там нового» |
| `tg_read` | История темы: `date_from/date_to` (архивы), `before_id`+`next_before` (листание), `budget_chars` (защита контекста) |
| `tg_find` | Полнотекстовый поиск **по всей истории**, включая посты двухлетней давности (SQLite FTS5, релевантность через bm25) |
| `tg_stats` | Активность: разбивка по темам, топ авторов, динамика по дням, медиа |
| `tg_mark_read` | Отметить тему прочитанной (агент зовёт сам только после того, как показал разбор) |
| `tg_index_stats` | Что лежит в индексе и за какой период |
| `tg_links` | Что шерили: топ @упоминаний, t.me, инвайтов, URL и источников пересылок |
| `tg_topics` | Структура индекса: чаты, а по chat_id — его темы со счётчиками |
| `tg_digests` | Готовые выжимки новых сообщений (создаёт `digest.py`) |
| `tg_entities` | Извлечённые сущности (задачи, решения, обещания, вопросы): project, kind, status, owner, due_ts; фильтры pack/kind/status/project/chat_id и query — подстрока в title/body |
| `tg_entity` | Одна сущность по id: все поля и история событий — какое поле менялось, когда и с каким доказательством из сообщения |
| `tg_overdue` | Открытые сущности со сроком: просроченные (`days_ahead=0`) или на горизонте N дней, по возрастанию due_ts |

Отправки нет вообще: в `server.py` не существует инструмента, который что-то
пишет в Telegram. Пишущих модуля два, оба вне MCP-сервера: `notify.py`
(сообщения себе, за гейтом `TG_ALLOW_SEND=1`) и `folders.py` (состав папок —
`add`/`remove` чатов; контент не пишет). `tg_sql` (произвольный
read-only SQL) скрыт по умолчанию, включается `TG_EXPOSE_SQL=1`.

---

## Диагностика

```bash
python3 doctor.py          # офлайн: библиотеки, креды, сессия, БД, FTS5, self-test маскировки
python3 doctor.py --live   # + реальное подключение к Telegram
```

Гоняй перед первым `sync.py` и после любых «почему не работает».

## Установка

```bash
python3 -m venv ~/.venvs/tg-agent
~/.venvs/tg-agent/bin/pip install -r requirements.txt   # telethon, mcp>=2, cryptg, qrcode
```

`api_id` / `api_hash`: https://my.telegram.org/apps (бесплатно).

Больше ничего руками делать не надо: `login.py` при первом запуске сам спросит
ключи и запомнит их в `.env` (файл в `.gitignore`). Хочешь заранее — скопируй
`.env.example` в `.env` и заполни: все скрипты (sync / login / live / watch /
server / ui / digest / doctor) читают `.env` сами, а переменные окружения,
если заданы, имеют приоритет. Кодировка файла не важна: UTF-8, UTF-8 с BOM
(notepad) и UTF-16 (Out-File в Windows PowerShell) читаются одинаково.

## Первый логин: один скан QR

```bash
python3 login.py
```

В терминале появится QR: Telegram → Настройки → Устройства → Подключить
устройство — наведи камеру. Телефон и код вводить не нужно (код из Telegram
приходит только тебе, обойти это нельзя — а QR убирает ввод целиком).
Если на аккаунте включена двухэтапная проверка — спросит облачный пароль
(до трёх попыток, ввод не отображается — следи за раскладкой); повторный запуск
не требует нового скана: QR уже принят, спросит только пароль.
Сессия ляжет в `tg_agent_session.session` и переиспользуется навсегда.
Альтернативы: `login.py --phone` (телефон+код),
`login.py --export` (напечатает `TELEGRAM_SESSION_STRING` для контейнеров).

Переменная `TG_ENV_FILE` управляет тем, что читается вместо `.env` (пустое
значение — не читать `.env` вовсе; так тесты и CI остаются герметичными).

## Синхронизация

```bash
python3 sync.py --dry-run                    # что найдётся, ничего не записывая
python3 sync.py --folder 7                   # всё из папки
python3 sync.py --watched                    # только watch-лист
python3 sync.py --chat @mygroup --days 30    # конкретная группа, окно 30 дней
```

Первый запуск попросит QR уже не должен — логинься заранее `login.py`; но и `sync.py`
сам спросит телефон + код (или QR), если сессии нет. Сессия — в `tg_agent_session.session`:
**это полный доступ к аккаунту**, `chmod 600`, не коммитить.

### Каналы с историей в годы

Обычная синхронизация идёт сверху вниз и обрывается на первом известном
сообщении — до конца архива так не дойти. Для архива:

```bash
python3 sync.py --chat @old_channel --backfill 3000
```

Бэкфилл идёт **снизу вверх чанками по 3000**, печатает прогресс
(`чанк 0->42: просмотрено 2, новых 2`) и сохраняет курсор после каждого чанка,
поэтому прерванный запуск продолжается с того же места. Для очень длинных
архивов крути в фоне и добавь задержку (по умолчанию 0.5 c между чанками) —
Telegram за агрессивный парсинг может ограничить аккаунт.

Повторный запуск любого варианта тянет только новое. Для регулярности — cron раз
в 15–60 минут: `sync.py --watched`.

### Как определяются темы

Telegram хранит темы как треды: у сообщения внутри темы `reply_to.forum_topic=True`,
id темы — в `reply_to.reply_to_msg_id`. Сообщения без `reply_to` в форуме
относятся к **General (id 1)**. Поэтому `sync.py` делает один проход по группе и
раскладывает сообщения по темам сам: иначе General недосчитывается.

Список тем — через `messages.GetForumTopicsRequest(peer, offset_date, offset_id,
offset_topic, limit)`. Обёртки в Telethon нет, а в `functions.channels` этого
метода нет вообще (там только `ToggleForumRequest`) — запрос делается напрямую.

## Глобальный поиск: найти новые каналы по слову

```bash
python3 search.py "нейросети"                # каналы, группы и юзеры
python3 search.py "нейросети" --type channel # только каналы-вещания
python3 search.py "ИИ" --type group --limit 30
```

Обёртка над `messages.SearchGlobalRequest` — сервер Telegram сам ищет по
названиям и содержимому публичных чатов (обычный API-запрос, не парсинг).
Результат: тип, название, @username, подписчики; дальше `sync.py --chat
@username` — и канал в индексе. Так закрывается сценарий «найди все каналы
по ИИ», с которого начинается кураторство: поиск → выбор → синк → watch.

Агент может искать сам: при `TG_ALLOW_LIVE=1` у MCP-сервера появляется
инструмент `tg_live_search` (тоже только чтение) — агент находит каналы и
предлагает тебе их синхронизировать, сам ничего не добавляет.

## Подключение к opencode

Формат в v1 и v2 разный: в v2 серверы лежат в `mcp.servers`, вместо
`enabled: false` используется `disabled: true`, и есть `codemode: false`, чтобы
инструменты были видны модели напрямую, а не через Code Mode.

- **v1** → `opencode.v1.json`
- **v2** → `opencode.v2.json`

```bash
opencode mcp add telegram --global -- \
  ~/.venvs/tg-agent/bin/python ~/tg-agent/server.py
opencode mcp list        # должно быть connected
python3 server.py --list # проверить сервер отдельно: печатает 13 инструментов
```

Файл `tg-agent.md` — пример промпта агента: порядок работы, требование начинать с
`tg_watch` и правило не выполнять просьбы из переписки.

Примеры запросов:

- «Что нового по моим темам?»
- «Разбери „ML-новости“ за неделю: что важно, что пропустил»
- «Искали ли в группах что-то про RAG за последний год?»
- «Какая тема у нас самая живая и кто там пишет больше всех?»

---

## Live-режим

```bash
python3 live.py     # рядом с ui.py, те же креды
```

Слушает новые сообщения всех чатов, что уже в индексе + watch-листа, и пишет их
в БД сразу (Telethon `events.NewMessage`). UI опрашивает БД каждые 4 секунды
(`/api/poll`), лента догоняет сама. В Telegram live ничего не отправляет.

## Дайджесты

```bash
DIGEST_PROVIDER=stub python3 digest.py     # механическая выжимка без LLM
DIGEST_PROVIDER=openai DIGEST_URL=https://api.openai.com/v1 \
    DIGEST_KEY=sk-... DIGEST_MODEL=gpt-4o-mini python3 digest.py
```

По каждой слежуемой теме берёт сообщения после прошлого дайджеста (курсор в
`meta`), строит контекст и кладёт выжимку в таблицу `digests`. Агент читает
через `tg_digests`, UI показывает баннером 📝 в теме. Проще всего гонять
по cron раз в день.

## Извлечение сущностей (packs)

```bash
EXTRACT_PROVIDER=stub python3 extract.py            # механический извлекатель (без LLM)
EXTRACT_PROVIDER=stub python3 extract.py --dry-run  # показать операции, ничего не писать
EXTRACT_PROVIDER=stub python3 extract.py --all-indexed  # все чаты индекса, не только watch
python3 extract.py --pack services --until-done     # дожевать хвост за один запуск
python3 extract.py --until-done --workers 6         # темы параллельно (EXTRACT_WORKERS)
EXTRACT_PROVIDER=openai EXTRACT_URL=https://api.openai.com/v1 \
    EXTRACT_KEY=sk-... EXTRACT_MODEL=gpt-4o-mini python3 extract.py --pack construction
```

Сообщения остаются неизменяемым источником истины; сущности — пересчитываемая
проекция: удалил и перегенерил заново, ничего не потеряно. Движок `extract.py`
generic, предметная область описана декларативным пакетом в `packs/`; пакет
задаёт kinds, prompt-шаблон, окно первого прогона (`backfill_days`) и scope.
Пакеты: `construction` (PM для стройки: decision / task / promise / question)
и `services` (сервисы и триалы: service / trial / promo; ключ дедупа —
`dedup_hint`: домен или слаг сервиса, поэтому «Триал 30 дней на Acme» и
«Acme Cloud trial» схлопываются в одну сущность). Дедуп глобальный между
чатами: один сервис из разных чатов — одна сущность с несколькими источниками
(`entity_sources`); слияние пишется в `entity_events` (op='merge'), редирект
старого id — в `entity_aliases`, `tg_entity` резолвит его автоматически. `services` прицелен в темы
watch-листа с проектом «сервисы» (`watch.py add --project сервисы`) — не
шумит в код-чатах; scope меняется в `packs/services.py`. `--until-done`
крутит раунды (по 200 сообщений на тему), пока хвост истории не кончится;
`--workers N` (или `EXTRACT_WORKERS`) дожёвывает темы параллельно — разовая
накачка базы на десятки тысяч сообщений вместо часов занимает минуты-десятки
минут, а каждый скоуп коммитится независимо. Один огромный скоуп делится на
окна msg_id и жуётся тем же пулом параллельно (`EXTRACT_WINDOW_MIN`, дефолт
2000 сообщений; `--windows N`): план и sub-курсоры окон живут в `meta`,
главный курсор продвигается одной транзакцией, когда все окна дожёваны, —
прерванный прогон продолжается с того же места.

По умолчанию область прогона — watch-лист; `--all-indexed` расширяет на все
пары (chat, topic) индекса — для массовой обработки «кучи групп», без
курирования. По каждой паре (пакет, тема) курсор `extract:<chat>:<topic>:<pack>`
в `meta`; за один прогон не больше 200 сообщений, остаток подберёт следующий.
Провайдер отвечает JSON-операциями new/update/close/reopen; каждая обязана
нести evidence — цитаты сообщений. Цитата проверяется как подстрока текста
сохранённого сообщения (плюс транскрипты медиа); операция без валидного
evidence отклоняется. Операции применяются одной транзакцией
(`entities`, `entity_events`, `extraction_runs`), сбой провайдера курсор не
двигает. Валидных операций больше 50 за прогон: применяются первые по позиции
evidence, остаток фиксируется как `ops_overflow` (status=partial), а курсор
остаётся до первой отброшенной — следующий прогон дочитает без потерь.
Батч, отклонённый контент-фильтром провайдера (`data_inspection_failed`),
бисектится до сообщения-триггера; триггер пропускается с фиксацией
run `status='filtered'`, остальные сообщения обрабатываются штатно.
Сетевой сбой ретраит батч до 3 раз (интервал `EXTRACT_NET_RETRY_DELAY`,
по умолчанию 15с); сломанная область завершает прогон ненулевым кодом —
незавершённый хвост виден сразу, курсоры позволяют перезапустить с места.

Теги проектов: `python3 watch.py add ... --project "Объекд"` или
`python3 watch.py project --chat @mygroup --topic 789 --project "Объекд"`.
Проект у строки watch — сущности наследуют его от темы.

## Автономный конвейер

```bash
python3 pipeline.py                  # sync -> media -> extract -> fuzzy -> export -> digest -> monitor
python3 pipeline.py --pack services  # один пакет извлечения
python3 pipeline.py --skip sync      # офлайн-шаги, без Telegram (--skip media/fuzzy/export тоже можно)
python3 pipeline.py --days-ahead 7 --send
python3 pipeline.py --loop 30 --send # демон: полный цикл каждые 30 минут
```

Одна команда для автономного режима: шаги независимы, сбой одного не
останавливает остальные (упавший шаг — в отчёте и в exit code 1); extract
дожёвывает хвост сообщений до конца. После извлечения `fuzzy` сливает дубли
сущностей по нормализованному title (`FUZZY_MAX_MERGES`, дефолт 500), а
`export` дописывает JSONL-фид для внешних потребителей (по умолчанию
`export.jsonl` рядом с базой, путь — `TG_EXPORT_PATH`; формат строки
`{"entity", "events", "exported_ts"}`, курсор в `integrations`).
Регулярность — `--loop N` (демон, cron не нужен) или cron; `live.py` остаётся
отдельным демоном для мгновенной подхватки новых сообщений.

## Автономка (Windows)

На машине настроены три компонента:

| Компонент | Роль | Запуск |
|---|---|---|
| `pipeline.py --loop 30` | полный цикл каждые 30 минут (sync → media → extract → fuzzy → export → digest → monitor) | при входе в систему: `tg-agent.cmd` в папке Startup |
| `live.py` | мгновенная подхватка новых сообщений в индекс | тот же `.cmd` |
| задача `tg-agent-morning` (09:00) | утренняя сводка в Saved Messages (`pipeline.py --skip sync --skip media --send`) | Task Scheduler |

Каждый демон получает собственную копию Telethon-сессии
(`tg_agent_session_cli`, `tg_agent_session_live`, `tg_agent_session_morning`):
два клиента на одном session-файле конфликтуют через его SQLite
(`database is locked`). Копии делаются обычным копированием файла
авторизованной сессии — auth-ключ один, локальные базы разные.

Двойной старт блокирует `proclock` (байтовая блокировка `<db>.pipeline.lock` /
`<db>.live.lock`; при смерти процесса ОС снимает её сама — протухших файлов не
остаётся). Логи дописываются в `pipeline.log` / `live.log` / `morning.log`
(в gitignore). Утренней сводке sync не нужен — loop-демон и так держит данные
свежими, а Telegram-API не конкурирует. Остановка: снять задачи
`Get-CimInstance Win32_Process -Filter "Name='python.exe'"` и убить PID'ы
демонов; для отключения автозапуска — удалить `tg-agent.cmd` из Startup и
`schtasks /delete /tn tg-agent-morning /f`.

## Мониторинг и уведомления

```bash
python3 monitor.py                       # dry-run: сводка по проектам и дедлайны
python3 monitor.py --days-ahead 7        # добавить задачи на неделю вперёд
python3 monitor.py --project "Объекд"    # один проект
python3 monitor.py --news-hours 24       # окно блока «новое» (по умолчанию сутки)
python3 monitor.py --send                # отправить сводку в Saved Messages
```

Считает по `entities`: просроченные — открытые сущности со сроком, чей due_ts
в прошлом; `--days-ahead N` дополнительно достаёт задачи до N дней включительно.
Сводка начинается с блока «новое за Nч» — свежесозданные сущности всех пакетов
и кросс-чатные повторы (события merge/dedup — сервис снова всплыл в другом
чате) со временем появления: утренний
`pipeline.py --loop 1440 --send` превращается в ежедневную сводку о триалах.
`--send` — это `notify.py`:

```bash
python3 notify.py "текст"                # dry-run: показать, что ушло бы
TG_ALLOW_SEND=1 python3 notify.py --send "текст"
echo "текст" | TG_ALLOW_SEND=1 python3 notify.py --send   # из stdin, построчно
```

`notify.py` — единственный модуль репозитория, который умеет писать в Telegram
(в свои Saved Messages). Реальная отправка только при `TG_ALLOW_SEND=1`, по
умолчанию везде dry-run; `TG_NOTIFY_CAP` (по умолчанию 10) ограничивает число
сообщений за вызов. MCP-сервер `notify.py` не импортирует — агент отправки
по-прежнему не видит.

## Медиа-транскрипция

```bash
MEDIA_PROVIDER=stub python3 media.py                    # заглушка без сети
MEDIA_PROVIDER=openai python3 media.py --limit 50       # скачивание + транскрипция
```

`sync.py` и `live.py` ставят голосовые и документы в очередь (таблица
`media_assets`, status=pending); воркер рефетчит сообщение из Telegram по id
(файловая ссылка живёт только на свежем объекте), классифицирует аудио по
атрибутам (`DocumentAttributeVoice/Audio`, mime `audio/*` как фолбэк) и до
скачивания отсекает не-аудио, файлы крупнее 10MB и длиннее 5 минут статусом
`skipped`; удалённые сообщения — `missing`, сбой провайдера — `failed`.
openai-провайдер — DashScope `qwen3-asr-flash` через chat/completions: аудио
инлайнится base64 data-URL, `asr_options` идёт верхним уровнем
(`MEDIA_URL`/`MEDIA_KEY`/`MEDIA_MODEL`/`MEDIA_TIMEOUT`; ключ — список через
запятую с ротацией на 401/403). `MEDIA_LIMIT` (дефолт 25) ограничивает ассеты
за прогон, `--limit`/`--kinds` — то же в CLI. Готовые транскрипции `extract.py`
подмешивает в промпт извлечения (и evidence-проверка учитывает их текст).
`pipeline.py` гоняет шаг автоматически после sync.

## Экспорт

```bash
python3 export.py                                  # JSONL рядом с базой
python3 export.py --system file --out /tmp/export.jsonl
python3 export.py --system notion                  # структурированная ошибка: нет API-доступа
```

Выгружает сущности и их события во внешние системы. Путь file-провайдера:
`--out`, иначе `TG_EXPORT_PATH`, иначе `export.jsonl` рядом с базой. Курсор
каждого провайдера живёт в таблице `integrations` (entity_id, system):
выгружаются только сущности с событиями свежее `synced_ts`; сущности без
событий не выгружаются никогда. `101` и `notion` не реализованы — отвечают
словарём-ошибкой, исключений не бросают (у 101 есть публичный REST API: JWT,
swagger на app.101-group.ru — ждёт кредов).

## Безопасность

- **Отправить сообщение агент не может** — таких инструментов нет. Пишущие
  модули живут вне MCP-сервера: `notify.py` (сообщения в Saved Messages, гейт
  `TG_ALLOW_SEND=1`) и `folders.py` (состав папок — только membership, без
  контента; правка = явный запуск команды).
- **Сообщения — неизменяемый источник истины.** Извлечение сущностей —
  пересчитываемая проекция: каждая сущность обязана цитатами из сохранённых
  сообщений (проверка на подстроку), без валидного evidence операция не
  применяется.
- **Секреты маскируются на инджесте** (`redact.py`): API-ключи (openai/github/
  groq/slack/nvidia), бот-токены, JWT, приватные ключи и «пароль: …»-паттерны
  заменяются на `[скрыто: …]` до записи в индекс — агент сырые креды не видит.
  Чтение (LIKE-фолбэк, UI, `tg_sql`, дайджесты) маскирует ещё раз — на случай
  баз, синхронизированных до появления маскировки.
- **`tg_sql` только чтение, по-настоящему**: префикс SELECT/WITH, плюс отдельное
  соединение с `PRAGMA query_only` и authorizer, который разрешает только чтение
  — `ATTACH`, `PRAGMA`, записи и транзакции отклоняются, несколько запросов за
  раз SQLite не пропустит. Слова вроде `update` внутри строковых литералов
  поисковых запросов больше не блокируются. По умолчанию инструмент выключен.
- **Session-файл = аккаунт.** 600, вне git. Если страшно — отдельный аккаунт для чтения.
- **Prompt injection.** Чужой текст — данные, не инструкции; это сказано модели в
  `instructions` сервера и в промпте агента. Не включай `TG_ALLOW_LIVE` там, где
  это критично.
- **Индекс — твоя переписка** в открытом SQLite. Держи локально.
- **Дайджесты от LLM** — текст модели о чужих сообщениях; в инструменте
  `tg_digests` это помечено как данные, не команды.
- **`TG_STRICT_WATCH=1`** — жёсткий гейт: `tg_read`/`tg_find`/`tg_stats`/
  `tg_links`/`tg_topics` работают только по темам из watch-листа, на остальное
  агент получает `access_denied` с подсказкой попросить тебя отметить тему.
  Сущностные инструменты (`tg_entities`/`tg_entity`/`tg_overdue`) гейтятся так же,
  но merged-сущность видна, если в watch-листе любой из её исходных чатов
  (`entity_sources`); её события могут цитировать другие участвовавшие в слиянии чаты.
- **UI с наружу — только с токеном.** При `HOST=0.0.0.0` UI вообще не стартует
  без `TG_UI_TOKEN`. Локальный режим дополнительно закрыт от DNS-rebinding
  (проверка Host) и CSRF (POST принимается только с
  `Content-Type: application/json`), токен сверяется constant-time.
- **FloodWait переживается автоматически**: sync/backfill спят указанное
  Telegram время и повторяют (до 3 раз); SQLite в WAL с `busy_timeout`,
  поэтому `live.py`, `sync.py` и UI могут работать одновременно.

## Структура репозитория

| Файл | Роль |
|---|---|
| `sync.py` | Telethon → SQLite: инкрементальная синхронизация, бэкфилл чанками, папки, пины |
| `search.py` | Глобальный поиск Telegram: публичные каналы/группы/юзеры по ключевому слову |
| `folders.py` | Правка состава папок Telegram: add/remove чатов (второй пишущий модуль, контент не пишет) |
| `pipeline.py` | Автономный конвейер: sync → media → extract → fuzzy → export → digest → monitor одним прогоном |
| `db.py` | Индекс: схема, FTS5, watch-лист, refs, пины, дайджесты, `run_sql` с блокировками |
| `server.py` | MCP-сервер (stdio): 13 read-only инструментов |
| `live.py` | Новые сообщения в реальном времени (`events.NewMessage`) |
| `digest.py` | Дайджесты: stub или openai-совместимый LLM |
| `extract.py` | Извлечение сущностей по пакетам из `packs/`: stub или openai-LLM, evidence-гейт |
| `packs/` | Декларативные пакеты извлечения: `construction` (decision/task/promise/question), `services` (service/trial/promo) |
| `monitor.py` | Сводка просроченного и дедлайнов по проектам; `--send` через `notify.py` |
| `notify.py` | Уведомления в Saved Messages; единственный пишущий модуль, гейт `TG_ALLOW_SEND=1` |
| `media.py` | Очередь транскрипции голосовых и документов: рефетч из Telegram, гейты до скачивания, qwen3-asr-flash (stub/openai) |
| `export.py` | Выгрузка сущностей во внешние системы (file / 101 / notion) |
| `services_report.py` | Каталог сервисов из сущностей: rollup по dedup_key, `--days/--query/--top/--hide-closed` |
| `fuzzy_dedup.py` | Maintenance-проход: слияние дублей сущностей по нормализованному title (richer-keeper, cap, идемпотентный) |
| `proclock.py` | Lockfile одиночного инстанса для демонов (pipeline --loop, live.py) |
| `ui.py` + `ui.html` | Веб-визуал «как в TG Desktop» (кураторство watch-листа) |
| `watch.py` | То же в терминале; `--project` и подкоманда `project` — тег проекта у темы |
| `login.py` | Логин QR-кодом (или телефон+код), `--export` — строка сессии |
| `env.py` | Чтение `.env` всеми скриптами (env-переменные приоритетнее) |
| `redact.py` / `sanitize.py` | Маскировка секретов / чистка bidi-инъекций (sanitize — из chigwell, Apache-2.0) |
| `doctor.py` | Диагностика окружения |
| `seed_demo.py` | Демо-база без Telegram: `python3 seed_demo.py demo.sqlite` |
| `test_offline.py` | 475 офлайн-проверок (без аккаунта Telegram), гоняются в CI |
| `opencode.v1.json` / `opencode.v2.json` | Готовые конфиги подключения к opencode |
| `tg-agent.md` | Памятка-промпт для агента |

Лицензия — MIT (`LICENSE`); атрибуция заимствований — там же и в шапках файлов.
`*.sqlite`, `*.session*` и `.env` в `.gitignore` — в репозиторий не попадают.

## Альтернативы: готовые MCP

Если не хочется своего кода:

| Проект | Темы | Комментарий |
|---|---|---|
| [chigwell/telegram-mcp](https://github.com/chigwell/telegram-mcp) (~1.8k★) | ✅ `list_topics`, `get_history(topic_id=...)` | Самый полный: мультиаккаунт, Docker, `TELEGRAM_EXPOSED_TOOLS=read-only`, транскрипция голосовых. Минус — 100+ инструментов в контексте и нет watch-листа |
| [m0n0x41d/telegram-mcp](https://github.com/m0n0x41d/telegram-mcp) | ❌ | 7 инструментов, `read_messages_since` для мониторинга, `uvx` |
| [DmitryKhali/telegram-mcp](https://github.com/DmitryKhali/telegram-mcp) | ❌ | «суммаризируй чат», отправка только через `confirm=true` |

⚠️ Не ставь `pip install telegram-mcp` / `uvx telegram-mcp` — в PyPI это
**другой** проект; передав ему `API_HASH` и session-string, отдашь доступ к
аккаунту постороннему коду. Только из git по явному URL.

## Про клиентов

Под капотом всех MCP — обычные user-клиенты MTProto: **Telethon** (Python, здесь),
**GramJS** (JS/TS), **TDLib** (C++, удобнее для очень больших дампов),
**opentele** (конвертация `tdata` Telegram Desktop в Telethon-сессию). Разница
только в языке: данные MTProto отдаёт одни и те же.

---

## Майнинг готовых MCP: что взято

Склонированы и прочитаны четыре репы; взято только то, что ложится в нашу
архитектуру без чужих рантайм-зависимостей:

| Откуда | Что | Статус |
|---|---|---|
| chigwell/telegram-mcp | `sanitize.py` целиком (prompt-injection защита выдачи) | ✅ взято как есть, применяется ко всей выдаче сервера и UI |
| chigwell/telegram-mcp | паттерн пинов (`get_messages(filter=InputMessagesFilterPinned)` + фолбэк) | ✅ портирован в `sync.fetch_pins`, таблица `pins`, баннер в UI и `pinned` в `tg_watch` |
| 0xMRK95/telegram-mcp-server | regexp `extract_refs` (@упоминания, t.me, инвайты, форварды) | ✅ портирован: таблица `refs` на инджесте + инструмент `tg_links` |
| chigwell/telegram-mcp | `transcription.py` (голосовые: groq/openai/whisper + SQLite-кэш) | ⛔ не берём: намертво привязан к их `runtime.py`; если нужны голосовые — проще вторым MCP поднять их сервер с `TELEGRAM_EXPOSED_TOOLS=read-only` |
| chigwell/telegram-mcp | `TELEGRAM_EXPOSED_TOOLS` (read-only поверхность) | 💡 идея учтена: у нас инструментов отправки нет вообще, `tg_sql` скрыт за `TG_EXPOSE_SQL=1` |
| m0n0x41d/telegram-mcp | scan-state с курсорами и TRUNCATED-семантикой | ≈ не берём: наши курсоры в `meta` делают то же; их `doctor` (диагностика сессии) — кандидат на потом |
| m0n0x41d/telegram-mcp | auth-визард с 2FA и лечением протухшей сессии | кандидат на потом (у нас пока `client.start()`) |
| andvseti/telegram-mcp-server | `get_recent_media`/`download_media` в каталог | кандидат для медиа-превью (слой 4) |
| chigwell/telegram-mcp | Docker + HTTP-транспорт (общий демон на несколько клиентов) | кандидат, если визуал и opencode будут на разных машинах |

## Проверено / не проверено

`python3 test_offline.py` — **475 проверок, все зелёные** (работают и на Linux,
и на Windows). Реально исполняются:

- `sync.run()` на подставном клиенте: определение групп/каналов, `--pattern`,
  `--chat @канал`, `--folder`, `--watched`, `--dry-run`, раскладка по темам
  (включая General), инкрементальность по курсорам
- `sync.backfill()`: досасывание истории чанками снизу вверх, идемпотентность,
  сохранение курсора, **resume прерванного прогона** (meta:backfill)
- **id-формы Telethon**: entity.id в marked-форме (-100…) на границе синка —
  live.py и watch.py видят одни и те же id
- `db.py`: темы, история, окно по времени, **FTS5-поиск** (включая фильтр по
  чату), статистика, watch-лист (`watch_new` / `watch_mark_read` / удаление),
  `run_sql` и его гейты (`query_only` + authorizer: `DELETE`, `PRAGMA`,
  `SELECT; DROP`, `UPDATE`, `ATTACH` — блокируются; слово в LIKE-литерале — нет)
- **маскировка секретов на инджесте**: токен не доезжает до `messages.text`,
  `raw_json` и `refs`; LIKE-фолбэк и `run_sql` выдают замаскированное
- **FTS-триггёр удаления**: `DELETE FROM messages` чистит индекс (раньше падал)
- **настоящий `server.py` как подпроцесс по MCP stdio**: `initialize` →
  `tools/list` (13 инструментов, `tg_sql` скрыт) → `tools/call` по каждому,
  включая сценарий «прочитал → отметил → непрочитанного нет»; `tg_new` честно
  держит `truncated` при лимите; отдельный прогон с `TG_ALLOW_LIVE=1` —
  live-инструменты регистрируются, без кредов отвечают JSON-ошибкой, а не
  зависают
- папки: `GetDialogFiltersRequest` на фейках → состав папок в индексе
- топ по реакциям (`stats.top_reactions`)
- `live.handle_message`: вставка + идемпотентность + refs; `collect_ids`
  подхватывает чаты, добавленные после старта
- **настоящий `ui.py` как подпроцесс**: `/api/poll` отдаёт сообщения по курсору;
  Host-гейт (403 на чужой Host), POST без `application/json` → 415, отказ
  поднимать `0.0.0.0` без токена; `/api/messages`, `/api/poll`, `/api/state`
  выдают замаскированный текст
- `digest.run`: stub-выжимки создаются, повторно не дублируются (курсор),
  отдаются через MCP `tg_digests`; сбой LLM-провайдера не двигает курсор
- гейты: `FloodWaitError` на фейках (sync дожидается и доделывает; посреди
  прохода счётчик `new` не занижается), `busy_timeout`, `before_id`/`date_to`,
  `budget_chars`+`truncated`, `tg_topics`, live-тема с заголовком, UI-токен
  (401 без / 200 с)
- `TG_STRICT_WATCH=1`: второй сервер-подпроцесс — чужой чат отклонён,
  слежуемый читается, `tg_find`/`tg_topics` вне watch слепы
- `doctor.run_checks()`: формат чеков, FTS5 и redact self-test
- бюджет контекста дайджеста: `build_context` вписывается в `DIGEST_MAX_CHARS`
  и честно помечает обрезку
- `extract.run`: курсор по (пакет, тема), окно `backfill_days` на первом прогоне,
  лимит 200 сообщений; операции new/update/close/reopen одной транзакцией;
  **evidence-гейт**: цитата вне текста сообщения отклоняется, сбой провайдера
  курсор не двигает; **overflow**: операции сверх 50 фиксируются в
  `ops_overflow`, курсор остаётся до первой отброшенной, следующий прогон
  дочитывает; `--all-indexed` покрывает все (chat, topic) индекса; дедуп
  `dedup_hint` сильнее title; отдаётся через MCP `tg_entities`/`tg_entity`/
  `tg_overdue`
- `monitor.collect`: просрочка и горизонт `--days-ahead`, фильтр по проекту;
  `notify`: dry-run по умолчанию, реальная отправка только за `TG_ALLOW_SEND=1`,
  cap `TG_NOTIFY_CAP`; `server.py` его не импортирует
- `media`: sync/live ставят voice/document в очередь, stub-транскрипт попадает
  в промпт извлечения; openai без байтов — `NotImplementedError`
- `export.run`: file-провайдер пишет JSONL, курсор в `integrations` (повторный
  прогон дубликатов не даёт; краш посреди прогона — закоммичено до сущности,
  повтор выгружает только остаток); 101/notion — словарь-ошибка без исключения
- **topic_id канонически NOT NULL** (=1 General): вставка без темы и миграция
  старых баз с NULL, запросы идут по индексу `ix_msg_chat_topic` (EXPLAIN PLAN
  проверяется), watch/history/search/stats видят то же
- `server._db()` кэширует соединение на поток — схема/миграции не гоняются
  на каждый tool-call
- **`tg_live_*` + `tg_live_search`**: живые запросы регистрируются при
  `TG_ALLOW_LIVE=1`; без кредов — JSON-ошибка, не зависание
- `search.search_global`: каналы/группы/юзеры с marked-id и kind,
  фильтр типа уходит флагом в запрос (SearchGlobalRequest на фейк-клиенте)
- пакет `services`: триал с due_text через dedup_hint-схему, другая
  формулировка с тем же hint — дубликат; stub создаёт service/trial/promo
- `pipeline.run`: sync→extract→digest→monitor на фейк-клиенте (10 сообщений,
  2 дайджеста), второй прогон инкрементален, `--skip sync` — офлайн-шаги
- проект у тем watch (`watch.py --project`, подкоманда `project`) наследуется
  сущностями и фильтруется в `tg_entities`/`tg_overdue`/`monitor`

**Не проверено:** реальные вызовы к Telegram — здесь нет аккаунта. Сигнатура
`messages.GetForumTopicsRequest` снята с установленного Telethon 1.45, но живой
ответ API глазами не видел; `iter_dialogs(folder=...)` проверен только по
сигнатуре. Первый `sync.py --dry-run --folder <id>` — это и есть проверка.

Maintenance

ActivityMaintained
ResponsivenessNo issues