Skip to main content
Glama
tg-agent (Telegram → SQLite → MCP)

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. агент получает 16 read-only инструментов, которые знают твои алиасы и «что нового с прошлого раза».

---

---

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

```bash
python3 start.py
```

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

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

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

### Вариант 0 — рабочее пространство в браузере

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

Главный экран **«Требует внимания»** показывает просрочки, блокировки, открытые
вопросы и непроверенные выводы. Разделы «Проекты», «Задачи», «Решения», «Вопросы»
ведут к карточке с историей и сообщениями-доказательствами; «Загрузки» —
раздел скачивания и пересылки медиа: задания, файлы, пересылки, подборки.
Светлая тема по
умолчанию, тёмная и системная доступны в настройках; мобильная компоновка —
без постоянных трёх колонок переписки.

- таблицы с фильтрами проекта, ответственного, статуса и срока, сортировкой и пагинацией;
- единый поиск по сущностям и сообщениям (FTS5 с безопасным LIKE-фолбэком),
  фильтры периода/проекта/человека, история поиска и локальные сохранённые представления;
- ручное исправление полей и проверка с аудитом; устаревшая версия карточки
  отклоняется с 409, новые события вновь требуют проверки;
- изменения проекта используют event-id и отдельную явную отметку просмотра:
  она не меняет `watch.last_read` и не подтверждает правильность извлечения;
- настройка привязки синхронизированных тем к проекту и ручной справочник людей;
- раздел «Переписка»: дерево чатов/тем, watch, алиасы, закрепление, отметки
  прочтения, дата, FTS, загрузка старых сообщений и копирование выдержки для агента;
- синхронизация и добавление чатов из Telegram запускаются только по нажатию,
  идущую синхронизацию можно отменить (`POST /api/sync/cancel`);
- раздел 101 показывает только локальное состояние и запросы-кандидаты. Он не
  выполняет авторизацию, проверку сети или отправку и не двигает курсоры экспорта.

**Ограничения:** контракт полей задач 101 ещё не подтверждён. Наличие токена или
локального GUID не означает работающую интеграцию. Отклонение записи человеком
является локальной отметкой: экспортёр пока не использует её как запрет отправки.
Настройки уведомлений позволяют посмотреть сводку без отправки; реальные
получатели по-прежнему задаются в `TG_NOTIFY_ROUTES` на сервере.

Выбор пишется в ту же таблицу `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
```

Создавать и настраивать сами чаты можно из терминала (реальные операции —
только при `TG_ALLOW_MANAGE=1`):

```bash
python3 manage.py create --title "Моя группа"
python3 manage.py edit --chat -1002000001 --title "Новое"
python3 manage.py topic-create --chat @forum --title "Тема"
```

## Релиз-посты и readme тем

```bash
python3 announce.py draft --repo ../tg-agent --tag v0.10.0 --product tg-agent --tpl-out post.yaml
python3 announce.py post --tpl post.yaml --chat @whitebite_devsoft --topic 323 --send --react 🔥 --pin
python3 announce.py readme --topic-title "🪡 Stitch · ядро" --chat @whitebite_devsoft --topic 325 --send
```

Пост собирается из YAML-шаблона: `badge`, `title`, `what`, `inside` (список),
`ps`, `warning`, `links`, `banner`. Баннер рендерится PIL в 1080x720, если в
шаблоне задан. Без `--send` любой режим — dry-run: печатает текст и сохраняет
баннер, к Telegram не обращается. Реальная отправка (кросспост `--also`,
реакция `--react`, пин `--pin`) только при `TG_ALLOW_MANAGE=1`. У MCP-сервера
есть зерцало для агента — инструмент `tg_manage_post`, регистрируется за тем же
гейтом и требует `confirm=True`.

### Вариант 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, инвайтов (с пометкой жива/истекла из кэша checklinks), URL и источников пересылок |
| `tg_topics` | Структура индекса: чаты, а по chat_id — его темы со счётчиками |
| `tg_digests` | Готовые выжимки новых сообщений (создаёт `digest.py`) |
| `tg_entities` | Извлечённые сущности (задачи, решения, обещания, вопросы): project, kind, status, owner, due_ts; фильтры pack/kind/status/project/owner/chat_id и query — подстрока в title/body |
| `tg_entity` | Одна сущность по id: все поля и история событий — какое поле менялось, когда и с каким доказательством из сообщения |
| `tg_overdue` | Открытые сущности со сроком: просроченные (`days_ahead=0`) или на горизонте N дней, по возрастанию due_ts |
| `tg_changes` | Сущности с событиями новее `since_ts`: что менялось после отметки, у каждой — события с цитатой-доказательством; фильтры pack/project |
| `tg_project_brief` | Сводка по проекту: counts по статусам, open, overdue, next_due, открытые вопросы, обещания без срока/ответственного, топ владельцев |
| `tg_gaps` | Пробелы по проекту или пакету: открытые вопросы без ответа, обещания без срока и без ответственного, с причиной попадания |

Отправки нет вообще — пока не включён гейт: по умолчанию в `server.py` нет
инструмента, который что-то пишет в Telegram. Пишущих модуля четыре, все вне
MCP-сервера по умолчанию: `notify.py` (сообщения себе, за гейтом
`TG_ALLOW_SEND=1`), `folders.py` (состав папок — `add`/`remove` чатов;
контент не пишет), `manage.py` (создание и настройку чатов, участники,
инвайт-ссылки; за гейтом `TG_ALLOW_MANAGE=1`) и `modules/downloads.py`
(скачивание медиа на диск — гейт `TG_ALLOW_DOWNLOADS=1`; пересылка медиа в
явно указанный чат — гейт `TG_ALLOW_FORWARDS=1`).
MCP-инструменты управления чатами (`tg_manage_*`) регистрируются только при
`TG_ALLOW_MANAGE=1` и требуют `confirm=True` для каждой операции;
`tg_downloads_run/status/collections` — при `TG_ALLOW_DOWNLOADS=1`,
`tg_downloads_forward` — при `TG_ALLOW_FORWARDS=1` (статус и подборки
регистрируются при любом из двух гейтов), запуск задания и пересылка требуют
`confirm=True`. `tg_sql` (произвольный
read-only SQL) скрыт по умолчанию, включается `TG_EXPOSE_SQL=1`.

---

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

```bash
python3 doctor.py          # офлайн: библиотеки, креды, сессия, БД, FTS5, self-test маскировки
python3 doctor.py --live   # + реальное подключение к Telegram
python3 doctor.py --providers  # opt-in: одна мини-компляция к настроенному extract/digest-провайдеру (живой ключ; мёртвый — HTTP 401)
python3 doctor.py --quality    # заполненность owner/due/body по пакетам + гистограмма причин отказа из rejects_json
```

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

Повторный запуск любого варианта тянет только новое, а также подхватывает
правки сообщений (`edit_date` → `edited_ts`; в отчёте — `edited_messages`).
Для регулярности — 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` (тоже только чтение) — агент находит каналы и
предлагает тебе их синхронизировать, сам ничего не добавляет.

## Проверка инвайт-ссылок: жива или истекла

```bash
python3 checklinks.py --link "https://t.me/+AbCdEf"   # конкретная ссылка
python3 checklinks.py                                 # инвайты из watch-чатов
python3 checklinks.py --newest 50                     # свежие из всего индекса
python3 checklinks.py --chat -100123 --days 7         # один чат, окно по времени
```

Обёртка над `messages.checkChatInvite` — тем же запросом, каким клиент
Telegram показывает превью ссылки до кнопки «Присоединиться»: **в чат не
вступает**. Живая ссылка отдаёт название, участников и флаг «вступление по
заявке»; истёкшая/невалидная фиксируется навсегда (хэш не оживает), живая
перепроверяется не чаще раза в `--max-age` дней (кэш в таблице
`invite_status`). Статусы видны офлайн: `tg_links` помечает инвайты
жива/истекла и даёт сводку. Между живыми запросами пауза `--delay` — за
серию проверок Telegram отвечает `INVITE_HASH_FLOOD` (FloodWait переживается).

Агент проверяет сам: при `TG_ALLOW_LIVE=1` есть инструмент
`tg_live_check_invites` — принимает ссылки списком или текстом сообщений,
сначала кэш, живыми запросами добирает только протухшее (не больше 5 за
вызов).

## Подключение к 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 # проверить сервер отдельно: печатает 16 инструментов
```

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

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

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

---

## Live-режим

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

Слушает новые сообщения всех чатов, что уже в индексе + watch-листа, и пишет их
в БД сразу (Telethon `events.NewMessage`); правки (`MessageEdited`) обновляют
строку с `edited_ts`, удаления (`MessageDeleted`) помечают `deleted=1`. 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`. За прогон не
больше `DIGEST_MAX_ROWS` строк (дефолт 500); хвост, не вошедший в окно,
подбирается следующим прогоном по курсору. Агент читает
через `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` резолвит его автоматически. Область
дедупа задаёт поле `dedup_scope`: у `construction` он по проекту, у `services`
остаётся глобальным. `services` прицелен в темы
watch-листа с проектом «сервисы» (`watch.py add --project сервисы`) — не
шумит в код-чатах; scope меняется в `packs/services.py`. В `packs/construction.py`
— `scope_filter.require_match` с `exclude_project_regex: "^сервисы$"`:
констракшн требует непустой проект и не запускается на проекте
«сервисы», скоупы изолированы. `--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 сообщений, остаток подберёт следующий.
Курсор после прогона всегда выставляется на максимальный msg_id обработанной
пачки (поле `covered_upto_msg_id` в операциях провайдера убрано; clamp по
overflow оставлен). Провайдер отвечает JSON-операциями new/update/close/reopen; каждая обязана
нести evidence — цитаты сообщений (`minItems: 1`). Цитата проверяется как подстрока текста
сохранённого сообщения (плюс транскрипты медиа); операция без валидного
evidence отклоняется, причины отказа пишутся в `extraction_runs.rejects_json`
(не больше 50 за прогон), нормализация цитаты сводит ё→е. Поле `confidence`
из схем пакетов убрано (колонка осталась, не заполняется). Операции применяются одной транзакцией
(`entities`, `entity_events`, `extraction_runs`), сбой провайдера курсор не
двигает. Срок из текста: если провайдер не дал `due_ts`, детерминированный
fallback в `extract.py` разбирает `due_text` — абсолютные даты (`20.10.2026`,
`20.10.26`), русские названия месяцев («19 августа»), «N числа», относительные
(«сегодня»/«завтра»/«послезавтра», «через N дней/недель/месяц») и ISO-даты —
всё резолвится по дате сообщения; срок ставится на конец дня (23:59:59 UTC),
а не на полночь. Update без явного `due_ts` больше не затирает ранее
выставленный точный срок.
`supersedes`: решение new/update может сослаться на открытую сущность того же
scope — цель закрывается, в `entity_events` пишется строка op='supersede'. Валидных операций больше 50 за прогон: применяются первые по позиции
evidence, остаток фиксируется как `ops_overflow` (status=partial), а курсор
остаётся до первой отброшенной — следующий прогон дочитает без потерь.
Батч, отклонённый контент-фильтром провайдера (`data_inspection_failed`),
бисектится до сообщения-триггера; триггер пропускается с фиксацией
run `status='filtered'`, остальные сообщения обрабатываются штатно.
Сетевой сбой ретраит батч до 3 раз (интервал `EXTRACT_NET_RETRY_DELAY`,
по умолчанию 15с); сломанная область завершает прогон ненулевым кодом —
незавершённый хвост виден сразу, курсоры позволяют перезапустить с места.
Отредактированные и удалённые сообщения перепрогоняются: id пишутся в
`meta`-ключ `dirty:{chat}:{topic}`, следующий прогон темы их снимает и
переизвлекает по ним.

Теги проектов: `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); каждый шаг
зажат по времени `PIPELINE_STEP_BUDGET` (дефолт 900с) — зависший шаг
помечается упавшим, конвейер не виснет; extract вне `--loop` дожёвывает хвост
до конца, в `--loop` — по окну за цикл (курсоры продолжают следующий).
`run()` захватывает локи `proclock`, поэтому разовый запуск не гоняется
параллельно с loop-демоном (лок занят — выход 0 «уже запущен»). В отчёте
`report["extract"]` — сводные `ops_rejected`/`ops_overflow`. После извлечения `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 не конкурирует. Задаче утренней сводки нужен
`TG_MONITOR_FORCE=1`: иначе дедуп monitor по хэшу отчёта глушит повторную
отправку в тот же день. Остановка: снять задачи
`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` превращается в ежедневную сводку о триалах.
Есть блок «зависшие вопросы» — открытые question старше
`MONITOR_QUESTION_DAYS` (дефолт 3). Сканы ограничены по объёму.
`--send` дедупится по хэшу отчёта (`meta['monitor:last_hash']`): одинаковая
сводка второй раз не отправляется, `TG_MONITOR_FORCE=1` обходит проверку.
`--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) ограничивает число
сообщений за вызов. Отправка идёт в worker-потоке на собственном event loop,
поэтому работает и изнутри уже запущенного цикла (`pipeline --send` раньше
падал с `asyncio.run() cannot be called from a running event loop`).
MCP-сервер `notify.py` не импортирует — агент отправки
по-прежнему не видит.

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

```bash
MEDIA_PROVIDER=stub python3 media.py                    # заглушка без сети
MEDIA_PROVIDER=openai python3 media.py --limit 50       # скачивание + транскрипция
python3 media.py --sweep-pending                        # разовый дренаж легаси-pending -> skipped, без скачивания
```

`sync.py` и `live.py` ставят в очередь только транскрибируемое аудио —
голосовые и аудио-документы (гейт `media.is_transcribable`, обычные документы
в очередь больше не попадают); воркер рефетчит сообщение из 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. Скачивание файла реализовано
(Telethon `download_media` в `_run_with_client`); если байтов аудио нет,
openai-провайдер отвечает `ValueError`. Готовые транскрипции `extract.py`
подмешивает в промпт извлечения (и evidence-проверка учитывает их текст).
`pipeline.py` гоняет шаг автоматически после sync. `--sweep-pending` — разовый
дренаж строк `pending`, накопившихся до гейта `is_transcribable`: одним UPDATE
они помечаются `skipped` и не скачиваются.

## Скачивание видео и медиа-библиотека

```bash
python3 downloads.py plan --sources watch --days 90   # задание без запуска
python3 downloads.py run --sources watch              # создать и выполнить
python3 downloads.py run --job 3 --retry --force      # продолжить/дожать
python3 downloads.py run --sources watch --mode forward --to saved
python3 downloads.py status [--job 3]                 # задания и библиотека
python3 downloads.py files [--chat -100123]           # файлы с числом источников
python3 downloads.py forwards [--chat -100123]        # пересланные медиа
python3 downloads.py collections save Видео --chats -100111,-100222
python3 downloads.py job pause|resume|cancel 3
```

Скачивает видео (по желанию videonote и photo) из отмеченных каналов, папки,
подборки, всех чатов индекса или точечного чата. Источники — спека через
запятую: `watch | all | folder:<id> | collection:<имя> | chat:<id|@username>`.
Подборка (`collections`) — сохранённая группа каналов для повторяемых заданий;
watch-лист и доступ агента она не меняет.

**Дедупликация точных дублей — два слоя.** До скачивания — по id файла
Telegram: пересланный в другой чат ролик не качается повторно. После
скачивания — по sha256 содержимого: тот же ролик, залитый заново, остаётся
одной копией, а второй id файла алиасится на уже скачанный файл. Хранение
контентно-адресное: `downloads/files/<sha[:2]>/<sha><ext>` — одна строка
`media_files` на уникальное содержимое, у файла накапливаются все
сообщения-источники (`media_file_sources`). Повторное задание по умолчанию
не включает уже скачанные сообщения.

Задание — снимок списка сообщений на момент создания (таблицы `jobs` /
`job_items`), поэтому прерванный прогон продолжается с места остановки, а не
начинается заново; `--retry` дожимает упавшие пункты, `pause`/`resume`/
`cancel` управляют ходом. Один пункт не обрывает пакет: причины пропуска
(`duplicate`, `duplicate-content`, `too-big`, `missing`, `kind`,
`chat-unavailable`) и падения — в отчёте задания. Лимит размера —
`TG_DOWNLOADS_MAX_MB` (дефолт 2000, 0 — без лимита), каталог библиотеки —
`TG_DOWNLOADS_DIR` (дефолт `downloads/` рядом с репо).

**Режим `forward` — пересылка без скачивания.** `--mode forward --to saved`
(или `@username` / числовой id своего чата) пересылает сообщения серверно:
байты медиа через клиент не идут, одна пересылка — один API-вызов. Дедуп
остаётся однослойным, по id файла Telegram и адресу (`forward_files` /
`forward_sources`): тот же файл из любого канала повторно не пересылается,
у записи накапливаются все сообщения-источники. Контентный sha256-дедуп здесь
невозможен — перезалитая копия с другим id пройдёт как новое видео. Ключ
реестра `(doc_id, адресат)`: смена адресата — свои пересылки заново (в план
берутся через `--all-messages`). Длинный FloodWait (>120 с) уводит задание в
`pause`, короткий пережидается молча; каналы с запретом пересылки
пропускаются (`forwards-restricted`). Пересылка — операция записи в Telegram,
поэтому MCP-инструмент `tg_downloads_forward` живёт за отдельным гейтом
`TG_ALLOW_FORWARDS=1` и требует `confirm=True`; HTTP-маршрут —
`POST downloads/forward`, список — `GET downloads/forwards`.

**Раздел «Загрузки» в рабочем пространстве.** `ui.py` показывает реестр
заданий с прогрессом и детали задания (параметры, счётчики, причины
пропусков, упавшие пункты) с кнопками «Запустить/Пауза/Продолжить/
Отменить/Дожать упавшие» и автообновлением, пока задание активно; форма
нового задания (источники-спека с быстрыми подстановками и подборками, режим
скачивание/пересылка, типы медиа, окно дней, лимит МБ) создаёт и запускает
прогон, списки файлов и пересылок фильтруются по чату, подборки создаются и
удаляются. UI использует те же маршруты `downloads/*`, что CLI и MCP.

Это первый модуль нового контракта: явный реестр `modules/` (dict: id,
version, schema, workspace-маршруты, MCP-спеки, runner). Ядро подключает
модуль в трёх точках — `db.py` (миграции v7/v8 создают таблицы модуля),
`workspace_api.py` (маршруты `downloads/*` в UI API) и `server.py`
(MCP-инструменты за гейтом; у каждой спеки опциональный `gate`:
`downloads`, `forwards`, `any`). Общая инфраструктура — `jobs.py`
(сохраняемые задания) и `sources.py` (выбор источников + подборки): следующий
модуль переиспользует их без правки ядра. `TG_MODULES_OFF="downloads"`
убирает модуль целиком — ни маршрутов, ни инструментов, ни таблиц.

Похожие, но не идентичные ролики (пережатые, обрезанные, с вотермаркой) —
кандидаты на слияние для человеческого решения: автоматически не удаляются и
не сливаются, это следующий шаг модуля.

## Экспорт

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

Выгружает сущности и их события во внешние системы. Путь file-провайдера:
`--out`, иначе `TG_EXPORT_PATH`, иначе `export.jsonl` рядом с базой. Курсор
каждого провайдера живёт в таблице `integrations` (entity_id, system):
выгружаются только сущности с событиями свежее курсора; сущности без
событий не выгружаются никогда. Курсор 101 — по `entity_events.id`, а не по
вторичному `ts` (события в одну секунду раньше молча терялись). `101` — реальный
REST-провайдер (stdlib urllib): `EXPORT_101_URL` (https://app.101-group.ru), auth —
`EXPORT_101_TOKEN` либо `EXPORT_101_LOGIN` + `EXPORT_101_PASSWORD` (вход
`/api/auth/sign-in/`, refresh пары на 401 через `/api/auth/refresh-token/`).
Сущности kinds из `EXPORT_101_KINDS` (дефолт `task,promise`) уходят задачами в
`EXPORT_101_PATH` (дефолт `/api/tasks/v2/`): create — POST, при наличии
`integrations.external_id` — PATCH `.../{guid}/`; `EXPORT_101_COMMENTS=1` пишет
событие комментарием; переименование полей под контракт 101 — `EXPORT_101_MAP`
(`{"title","body","due_ts"}` → поля 101); `EXPORT_101_DRY=1` — показать, что уедет,
без отправки (курсор не двигается). 5xx/сеть ретраятся (`EXPORT_101_RETRIES`,
`EXPORT_101_BACKOFF`), 4xx не ретраится; упавшая сущность не обрывает прогон (уходит
в `failed`), `external_id` из ответа (guid) пишется в `integrations.external_id`,
курсор — только после 2xx; без кредов — структурированный `{"ok": false, "error": ...}`
без исключения. Тела задач в сваггере 101 нет — дефолтный `EXPORT_101_MAP`
консервативен и настраивается. `notion` остаётся заглушкой со словарём-ошибкой.

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

- **Отправить сообщение агент не может** — по умолчанию таких инструментов
  нет. Пишущие
  модули живут вне MCP-сервера: `notify.py` (сообщения в Saved Messages, гейт
  `TG_ALLOW_SEND=1`), `folders.py` (состав папок — только membership, без
  контента; правка = явный запуск команды), `manage.py` (создание и настройка
  чатов, участники, инвайт-ссылки; гейт `TG_ALLOW_MANAGE=1`) и downloads-модуль
  (скачивание медиа на диск — гейт `TG_ALLOW_DOWNLOADS=1`; пересылка медиа в
  явно указанный чат — гейт `TG_ALLOW_FORWARDS=1`).
  MCP-инструменты управления чатами (`tg_manage_*`) регистрируются только при
  `TG_ALLOW_MANAGE=1` и требуют `confirm=True` для каждой операции;
  `tg_downloads_run/status/collections` — при `TG_ALLOW_DOWNLOADS=1`,
  `tg_downloads_forward` — при `TG_ALLOW_FORWARDS=1`; запуск задания и
  пересылка требуют `confirm=True`.
- **Сообщения — источник истины, правки подхватываются.** Строка отражает
  последнее состояние текста (`edited_ts`); предыдущие версии правок не
  сохраняются. Удаление — флаг `deleted=1`: строка остаётся в базе, но
  читалки (история, `tg_new`, поиск, `_around`) её отдают с фильтром
  `deleted=0`, а `mark_deleted` вычищает строку из FTS — удалённое не
  ищется, отредактированное ищется по новому тексту (триггер FTS —
  `AFTER UPDATE OF text`). Сущности — пересчитываемая проекция: каждая обязана цитатами из
  сохранённых сообщений (проверка на подстроку), без валидного evidence
  операция не применяется; цитата в `entity_events` может ссылаться на текст
  до правки.
- **Секреты маскируются на инджесте** (`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`/`tg_changes`/
  `tg_gaps`) гейтятся так же,
  но 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: инкрементальная синхронизация, бэкфилл чанками, папки, пины, подхват правок (`edited_ts`) |
| `search.py` | Глобальный поиск Telegram: публичные каналы/группы/юзеры по ключевому слову |
| `checklinks.py` | Статус инвайт-ссылок (checkChatInvite без вступления): CLI + кэш `invite_status`, терминальные expired/invalid, перепроверка живых не чаще раза в N дней, FloodWait-пacing |
| `folders.py` | Правка состава папок Telegram: add/remove чатов (пишущий модуль, контент не пишет) |
| `manage.py` | Создание и управление чатами: группы, каналы, форумы, настройки, участники (гейт `TG_ALLOW_MANAGE=1`) |
| `announce.py` | Релиз-посты: баннер PIL, шаблон YAML, readme тем из org.yaml, черновик из CHANGELOG; гейт `TG_ALLOW_MANAGE=1` |
| `org.yaml` | Реестр продуктов WhiteBite: репо, ссылки, эмодзи, разметка тем |
| `pipeline.py` | Автономный конвейер: sync → media → extract → fuzzy → export → digest → monitor одним прогоном; бюджет шага, proclock-лок, ops-сводка в отчёте |
| `db.py` | Индекс: схема с миграциями по `PRAGMA user_version`, FTS5, watch-лист, refs, пины, дайджесты, правки/удаления сообщений (`update_message` / `mark_deleted`, удалённые скрыты от читалок и FTS), read-only соединение (`DB(path, readonly=True)`), каскад проекта в `watch_set_project`, `run_sql` с блокировками |
| `server.py` | MCP-сервер (stdio): 16 read-only инструментов; `tg_mark_read` отклоняет id выше максимума индекса темы |
| `live.py` | Новые сообщения в реальном времени (`events.NewMessage`), правки и удаления (`MessageEdited` / `MessageDeleted`) |
| `digest.py` | Дайджесты: stub или openai-совместимый LLM |
| `extract.py` | Извлечение сущностей по пакетам из `packs/`: stub или openai-LLM, evidence-гейт, due_text→due_ts fallback (ru-месяцы, относительные, конец дня), supersedes, причины отказа в `rejects_json`, перепрогон dirty |
| `packs/` | Декларативные пакеты извлечения: `construction` (decision/task/promise/question), `services` (service/trial/promo); scope-фильтры (`require_match`, `exclude_project_regex`) и `dedup_scope` |
| `monitor.py` | Сводка просроченного и дедлайнов по проектам, зависшие вопросы; `--send` через `notify.py`, дедуп по хэшу отчёта |
| `notify.py` | Уведомления в Saved Messages; пишущий модуль, гейт `TG_ALLOW_SEND=1` |
| `media.py` | Очередь транскрипции аудио (voice + audio-документы): гейт `is_transcribable` на инджесте, рефетч и скачивание из Telegram, гейты до скачивания, `--sweep-pending`, qwen3-asr-flash (stub/openai) |
| `modules/` | Реестр функциональных модулей (контракт: id, version, schema, workspace-маршруты, MCP-спеки, runner; `TG_MODULES_OFF`); `modules/downloads.py` — скачивание медиа с дедупом |
| `downloads.py` | CLI модуля скачивания: план, прогон, статус, файлы, подборки, управление заданием |
| `jobs.py` | Сохраняемые задания: `jobs`/`job_items`, claim с защитой от гонок, pause/resume/cancel, retry упавших, отчёт по пунктам |
| `sources.py` | Выбор источников: `watch`/`all`/`folder:`/`collection:`/`chat:` (объединение через запятую) и подборки каналов |
| `export.py` | Выгрузка сущностей во внешние системы (file / 101 — REST-задачи с JWT sign-in+refresh, id-курсор, ретраи, upsert по guid; 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` + `ui_assets/` | Рабочее пространство в браузере: «Требует внимания», сущности, «Переписка», «Загрузки»; раздаёт `/assets/*` и `/api/workspace/*` за общим токен-гейтом, синхронизацию можно отменить |
| `workspace_store.py` / `workspace_views.py` / `workspace_write.py` / `workspace_search.py` / `workspace_api.py` | Бэкенд рабочего пространства: фильтры/сортировка/пагинация, поиск FTS5+LIKE-фоллбек, правка полей с аудитом (409 на устаревшую ревизию), проверка карточек, курсор просмотра проекта, привязка тем, справочник людей, локальные превью 101 и уведомлений |
| `DESIGN.md` | Дизайн-спека рабочего пространства: палитра, типографика, сетка, светлая/тёмная темы |
| `watch.py` | То же в терминале; `--project` и подкоманда `project` — тег проекта у темы |
| `login.py` | Логин QR-кодом (или телефон+код), `--export` — строка сессии |
| `env.py` | Чтение `.env` всеми скриптами (env-переменные приоритетнее) |
| `redact.py` / `sanitize.py` | Маскировка секретов / чистка bidi-инъекций (sanitize — из chigwell, Apache-2.0) |
| `doctor.py` | Диагностика окружения; `--providers` (opt-in health-check провайдера), `--quality` (заполненность полей и гистограмма отказов), `--cost` (расход токенов по `extraction_runs`) |
| `seed_demo.py` | Демо-база без Telegram: `python3 seed_demo.py demo.sqlite` |
| `test_offline.py` | 1275 офлайн-проверок (без аккаунта Telegram) — свои проверки плюс пятнадцать suites ниже подпроцессами, гоняются в CI; своя temp-база через `tempfile.mkdtemp` |
| `tests_index_media.py` | правки/удаления сообщений в индекс, гейт медиа-очереди (запускается из `test_offline.py`) |
| `tests_packs_semantics.py` | пак: срок из due_text, no-downgrade, изоляция по проекту, supersedes, схемы пакетов (запускается из `test_offline.py`) |
| `tests_export_101.py` | HTTP-экспорт 101 против локального фейк-сервера (запускается из `test_offline.py`) |
| `tests_ops_report.py` | операционная автономка: notify в живом loop, бюджет шагов, lock, дедуп сводки, doctor --quality (запускается из `test_offline.py`) |
| `tests_media_ingest.py` | sweep pending + граница tg_mark_read (запускается из `test_offline.py`) |
| `tests_db_core.py` | user_version-миграции, read-only, deleted/FTS, dirty, каскад проекта (запускается из `test_offline.py`) |
| `tests_readers.py` | читалки: изменения с прошлого раза, бриф проекта, пробелы, фильтр owner (запускается из `test_offline.py`) |
| `tests_pack_schema.py` | валидация схем паков при загрузке (запускается из `test_offline.py`) |
| `tests_watchdog.py` | свежесть прогона конвейера (`pipeline:last_ok_ts`) (запускается из `test_offline.py`) |
| `tests_ui_projects.py` | `/api/projects` и курсор визита проекта в UI (запускается из `test_offline.py`) |
| `tests_manage.py` | Офлайн-тесты manage.py: создание, настройки, темы, участники, MCP (запускается из `test_offline.py`) |
| `tests_workspace_api.py` | HTTP-тесты workspace-API на реальном сервере: фильтры, 409-ревизии, 401/415, отмена синхронизации, секреты не утекают, превью 101 без сети (запускается из `test_offline.py`) |
| `tests_question_signals.py` | сигналы по вопросам: ответ/реакции не закрывают вопрос (запускается из `test_offline.py`) |
| `tests_announce.py` | Офлайн-тесты announce.py: рендер баннера, сборка текста, post с отправкой на фейк-клиенте, readme из org.yaml, draft из CHANGELOG, гейт (запускается из `test_offline.py`) |
| `tests_downloads.py` | Офлайн-тесты downloads-модуля: дедуп doc-id/sha256 + алиасы, forward-режим (дедуп по файлу и адресату, FloodWait-пауза, запрет пересылки), resume, пауза/отмена, подборки, реестр и `TG_MODULES_OFF`, MCP-гейты `TG_ALLOW_DOWNLOADS`/`TG_ALLOW_FORWARDS`, фоновый загрузчик (запускается из `test_offline.py`) |
| `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` — **1275 проверок: свои проверки плюс пятнадцать suites
подпроцессами (`tests_index_media.py`, `tests_packs_semantics.py`,
`tests_export_101.py`, `tests_ops_report.py`, `tests_media_ingest.py`,
`tests_db_core.py`, `tests_readers.py`, `tests_pack_schema.py`,
`tests_watchdog.py`, `tests_ui_projects.py`, `tests_manage.py`,
`tests_announce.py`, `tests_workspace_api.py`, `tests_question_signals.py`,
`tests_downloads.py`) — все зелёные** (работают и на 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` / удаление),
  миграции по `PRAGMA user_version` (DDL не повторяется на каждом открытии),
  read-only-путь (`DB(readonly=True)` без миграций и с `query_only`), каскад
  проекта в `watch_set_project`, скрытие `deleted=1` от читалок;
  `run_sql` и его гейты (`query_only` + authorizer: `DELETE`, `PRAGMA`,
  `SELECT; DROP`, `UPDATE`, `ATTACH` — блокируются; слово в LIKE-литерале — нет)
- **маскировка секретов на инджесте**: токен не доезжает до `messages.text`,
  `raw_json` и `refs`; LIKE-фолбэк и `run_sql` выдают замаскированное
- **FTS-триггёр удаления**: `DELETE FROM messages` чистит индекс (раньше падал)
- **правки/удаления в индексе**: `db.update_message` / `db.mark_deleted`, FTS
  отдаёт новый текст после правки, удалённое скрыто от читалок и из FTS; sync подхватывает `edit_date` в rescan и
  backfill (`edited_messages` в отчёте); `live.handle_edit` /
  `live.handle_delete` на подставных событиях
- **статусная модель**: `open / in_progress / blocked / done` (терминал — `closed_ts`);
  ops `start/block/unblock` с проверкой переходов, `close`→`done`; миграция v3
  (`closed`→`done`); `update` не трогает статус
- **сигналы по вопросам**: ответ другого отправителя (`reply_received`) и
  реакции (`reaction_received`) требуют проверки, но сами не закрывают вопрос;
  завершение остаётся явной операцией извлечения или ручным исправлением статуса
- **реакции в индексе**: `live.handle_reactions` + `db.update_reactions` (агрегатные счётчики)
- **читалки агента**: изменения с прошлого раза, бриф проекта, пробелы (вопросы без
  ответа, обещания без срока/ответственного), фильтр по ответственному
- **MCP**: 16 инструментов — `tg_changes` / `tg_project_brief` / `tg_gaps` + `owner` в `tg_entities`
- **маршрутизация сводок** по проектам (`TG_NOTIFY_ROUTES`, персональные хэш-ключи);
  `monitor` показывает обещания без срока/ответственного и «застряло» (blocked+просрочка)
- **наблюдаемость**: `doctor --cost`, штамп `pipeline:last_ok_ts` + `watchdog.py`, UI-панель
  `/api/projects` с курсором визита проекта
- **гигиена**: валидация схем паков на импорте, провайдеры экспорта из конфига (`TG_EXTRA_PROVIDERS`)
- **настоящий `server.py` как подпроцесс по MCP stdio**: `initialize` →
  `tools/list` (16 инструментов, `tg_sql` скрыт) → `tools/call` по каждому,
  включая сценарий «прочитал → отметил → непрочитанного нет»; `tg_new` честно
  держит `truncated` при лимите; `tg_mark_read` отклоняет id выше максимума
  индекса темы; отдельный прогон с `TG_ALLOW_LIVE=1` —
  live-инструменты регистрируются, без кредов отвечают JSON-ошибкой, а не
  зависают
- **downloads-модуль**: дедуп по id файла Telegram до скачивания и по sha256
  после (повторная загрузка алиасится на файл), у файла все
  сообщения-источники, контентно-адресное хранение; forward-режим — пересылка
  без скачивания: дедуп по `(id файла, адресат)`, длинный FloodWait уводит
  задание в `pause`, короткий пережидается, запрет пересылки — skip;
  resume упавших пунктов
  (`--retry --force`), пауза/отмена задания, подборки каналов, реестр модулей
  и `TG_MODULES_OFF`, миграции v6→v8 и v7→v8 (таблицы пересылок довариваются
  в существующую базу); MCP-гейты `TG_ALLOW_DOWNLOADS=1` (16+3) и
  `TG_ALLOW_FORWARDS=1` (16+3), оба — 16+4; запуск и пересылка —
   `confirm=True`; фоновый загрузчик без кредов помечает задание
   failed и не зависает
- **workspace-API**: реальный HTTP-сервер из `ui.py` (ThreadingHTTPServer,
  токен): фильтры/сортировка/пагинация сущностей, правка полей с 409 на
  устаревшую ревизию, 401 без токена и 415 без JSON, отмена незапущенной
  синхронизации — 409, секреты маскируются,
  превью 101 не ходит в сеть и не двигает экспортные курсоры
- папки: `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; `--quality`
  (заполненность полей по пакетам + гистограмма причин из `rejects_json`) и
  `--providers` (без настроенного openai-провайдера сеть не трогается)
- бюджет контекста дайджеста: `build_context` вписывается в `DIGEST_MAX_CHARS`
  и честно помечает обрезку; строки за прогон ограничены `DIGEST_MAX_ROWS`
  (дефолт 500), хвост подбирается курсором
- `extract.run`: курсор по (пакет, тема), окно `backfill_days` на первом прогоне,
  лимит 200 сообщений; операции new/update/close/reopen одной транзакцией;
  **evidence-гейт**: цитата вне текста сообщения отклоняется, причины отказа
  пишутся в `rejects_json`, сбой провайдера
  курсор не двигает; **overflow**: операции сверх 50 фиксируются в
  `ops_overflow`, курсор остаётся до первой отброшенной, следующий прогон
  дочитывает; `--all-indexed` покрывает все (chat, topic) индекса; дедуп
  `dedup_hint` сильнее title; отдаётся через MCP `tg_entities`/`tg_entity`/
  `tg_overdue`
- `monitor.collect`: просрочка и горизонт `--days-ahead`, фильтр по проекту,
  блок зависших вопросов; дедуп `--send` по хэшу отчёта с обходом через
  `TG_MONITOR_FORCE=1`;
  `notify`: dry-run по умолчанию, реальная отправка только за `TG_ALLOW_SEND=1`,
  cap `TG_NOTIFY_CAP`; `server.py` его не импортирует
- `media`: sync/live ставят в очередь только транскрибируемое аудио (voice +
  audio-документы, гейт `is_transcribable`), stub-транскрипт попадает
  в промпт извлечения; openai без скачанных байтов — `ValueError`;
  `--sweep-pending` помечает легаси-pending как skipped одним UPDATE
- `export.run`: file-провайдер пишет JSONL, курсор в `integrations` (повторный
  прогон дубликатов не даёт; краш посреди прогона — закоммичено до сущности,
  повтор выгружает только остаток); 101 — реальный HTTP-провайдер, проверен
  против локального фейк-сервера (POST, тело, id-курсор по `entity_events.id`,
  ретраи на 5xx, continue-on-error со списком `failed`, `Idempotency-Key`,
  запись `external_id`, курсор только после 2xx, ошибка
  без кредов); notion — словарь-ошибка без исключения. Живой пуш в 101 не
  проверялся (нет кредов/контракта)
- **topic_id канонически NOT NULL** (=1 General): вставка без темы и миграция
  старых баз с NULL, запросы идут по индексу `ix_msg_chat_topic` (EXPLAIN PLAN
  проверяется), watch/history/search/stats видят то же
- `server._db()` кэширует соединение на поток — схема/миграции не гоняются
  на каждый tool-call; миграции дополнительно гейтятся `PRAGMA user_version`
- **`tg_live_*` + `tg_live_search`**: живые запросы регистрируются при
  `TG_ALLOW_LIVE=1`; без кредов — JSON-ошибка, не зависание
- `search.search_global`: каналы/группы/юзеры с marked-id и kind,
  фильтр типа уходит флагом в запрос (SearchGlobalRequest на фейк-клиенте)
- `checklinks`: хэш из +/joinchat/текста/сырого вида, checkChatInvite на
  фейк-клиенте (превью ok, member, expired/invalid терминальные, FloodWait
  повтором, сетевой сбой без падения прогона), кэш invite_status (свежее не
  перепроверяется, протухшее да, force не трогает терминальные, live_cap),
  invite_refs дедупит и сортирует по свежести, refs_summary джойнит статусы,
  миграция v5→v6; `tg_live_check_invites` без кредов — JSON-ошибка
- пакет `services`: триал с due_text через dedup_hint-схему, другая
  формулировка с тем же hint — дубликат; stub создаёт service/trial/promo
- семантика паков (`tests_packs_semantics.py`): детерминированный `due_text` →
  `due_ts` (абсолютные даты, ru-месяцы, относительные, ISO — по дате сообщения,
  конец дня вместо полуночи), no-downgrade точного срока при update; изоляция
  construction от проекта «сервисы» через `require_match` + `exclude_project_regex`;
  `dedup_scope` project/global; схемы пакетов: evidence `minItems=1`, без
  `confidence`; supersedes:
  цель закрывается, событие op='supersede' в `entity_events`. Качество и
  частота извлечения на реальных чатах не проверены: rate `promise` и реальная
  заполняемость due/supersedes ждут живого прогона `EXTRACT_PROVIDER=openai`
- `pipeline.run`: sync→extract→digest→monitor на фейк-клиенте (10 сообщений,
  2 дайджеста), второй прогон инкрементален, `--skip sync` — офлайн-шаги;
  в отчёте extract — сводные `ops_rejected`/`ops_overflow`
- операционная автономка (`tests_ops_report.py`): notify в живом event loop
  (worker-поток), бюджет шага `PIPELINE_STEP_BUDGET`, proclock-лок one-shot,
  дедуп сводки monitor, зависшие вопросы, `doctor --quality`;
  `tests_media_ingest.py`: sweep pending и граница `tg_mark_read`;
  `tests_db_core.py`: user_version-миграции, read-only, deleted/FTS, dirty,
  каскад проекта
- проект у тем watch (`watch.py --project`, подкоманда `project`) наследуется
  сущностями и фильтруется в `tg_entities`/`tg_overdue`/`monitor`
- `manage.py` (`tests_manage.py`): создание групп/каналов/форумов на фейк-клиенте
  с регистрацией в индексе (`created_by_me=1`, `my_role=owner`), настройки
  (title/about/photo/username, тумблеры, дефолтные права), темы форума,
  участники и инвайт-ссылки; гейт `TG_ALLOW_MANAGE` (без него все операции
  заблокированы без запросов); MCP `tg_manage_*` регистрируются только при
  гейте и требуют `confirm=True`
- `announce.py` (`tests_announce.py`): рендер баннера PIL (1080x720), сборка
  текста из YAML-шаблона, post с кросспостом/реакцией/пином на фейк-клиенте и
  запись в таблицу `posts` (миграция v5), readme темы из org.yaml, черновик
  из CHANGELOG репозитория; гейт `TG_ALLOW_MANAGE` — без него отправка
  блокируется без запросов; MCP `tg_manage_post` требует `confirm=True`.
  Живые посты в Telegram не проверены

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