Files
Deal/.superpowers/sdd/channel-discovery/task-4-report.md
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

203 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Task 4 — Отчёт: Telegram-действия поиска (методы TelegramManager)
Статус: **DONE**
## Что сделано по шагам
### Step 1: Методы в `TelegramManager` (`backend/app/services/telegram.py`)
Добавлены в конец класса (секция `# ── discovery ...`), рядом с `dialog_messages`:
- `discovery_search(q, limit=30) -> list[dict]``client(functions.contacts.SearchRequest(...))`;
после запроса `await asyncio.sleep(ban_guard.search_pause())` (пауза «между поисками»).
Возвращает `[{id, name, username, kind, hue}]`:
- `id`**подписанный** peer id (`utils.get_peer_id(entity)`), т.е. формат совпадает с
`str(dlg.id)` в таблице `dialogs` (каналы `-100…`, базовые группы `-id`, люди `+id`).
Это нужно для глобального правила «мы не состоим» (сравнение с `dialogs`).
- `name``utils.get_display_name(entity)` (как `Dialog.name`), `kind``_kind_of`, `hue``dialog_hue`.
- Результаты дедуплицируются по `id`.
- `discovery_info(dialog_id) -> dict``{id, name, username, kind, hue, participants, is_forum}`:
участники из `full_chat` (`GetFullChannelRequest` для каналов/супергрупп,
`GetFullChatRequest` для базовых групп); при любой ошибке/недоступности — `participants=None`,
исключение наружу не бросается.
- `discovery_read(dialog_id, limit) -> dict``{"ok", "error", "messages":[{id, text, date_ms, topic_id, topic_title}]}`;
для форумов — выборка по активным темам (`channels.getForumTopics` + чтение каждой темы),
плоский список с `topic_id`/`topic_title`; для обычных источников оба поля = `None`.
История недоступна → `{"ok": False, "error": "no_history", "messages": []}`. Сообщения без
текста (медиа/сервисные) пропускаются (как в `dialog_messages`/backfill). `limit <= 0` → пустой
`ok` без сетевых вызовов. (правка тем форума — в «Fix round 1»)
- `discovery_join(username) -> None``get_entity(username)` + `JoinChannelRequest`;
`FloodWaitError``ban_guard.note_flood()` + `raise`. Пауза/квоты НЕ внутри — ручной join из API
вне квот, `wait_join_delay()` перед авто-вступлением вызывает воркер (Task 6).
Пустой username → `ValueError`. (правка — в «Fix round 1»)
- `discovery_leave(dialog_id) -> None``LeaveChannelRequest`.
- `add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — синхронный upsert в `dialogs`
(`monitor=TRUE, backfilled=FALSE`, `ON CONFLICT DO UPDATE` — по образцу `set_monitor`/`_persist_dialogs`)
+ `_reload_monitored()`. Без авто-логики (никакого `_spawn(backfill)` — как в брифе).
Импорты: `from telethon import ..., utils`, `FloodWaitError` (`telethon.errors.rpcerrorlist`),
`functions` (`telethon.tl`), `from . import ban_guard`. `progress.md` не трогал.
### Step 2: Проверка
Выполнена (см. ниже). Живых Telegram-вызовов не делалось (E2E — Task 10).
## Изменённые файлы
- `backend/app/services/telegram.py` (импорты + 6 методов класса `TelegramManager`)
## Вывод проверок
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py backend/app/services/ban_guard.py
PY_COMPILE_OK # без ошибок
```
Дополнительно (без сети, на временной БД `LEADRADAR_DATA=/tmp/lr_t4*`):
- импорт `from app.services import telegram, ban_guard``IMPORT_OK`, методы присутствуют;
- офлайн-сценарии: `discovery_search` без клиента → `RuntimeError`; `discovery_info` без клиента →
словарь с `participants=None, is_forum=False`; `discovery_read``no_history``ok=True` при `limit<=0`);
`discovery_join('')``ValueError`; `discovery_leave` без клиента → `RuntimeError`;
`add_dialog_monitored` → строка `monitor=TRUE, backfilled=FALSE` в `dialogs` + `_monitored` обновлён;
`dialog_messages` (существующий метод) не сломан — `OFFLINE_OK`.
## Concerns
### Доступность полей Telethon (проверено интроспекцией установленного telethon==1.37.0)
- **participants**: авторитетный источник — `ChannelFull.participants_count` из
`channels.GetFullChannelRequest` (каналы и супергруппы; работает для публичных каналов и без
вступления). У самого `Channel` тоже есть `participants_count: Optional[int]`, но он не гарантирован,
поэтому в коде берём полный чат. Для базовой группы `ChatFull.participants` имеет тип
`ChatParticipants | ChatParticipantsForbidden` — считаем `len(participants.participants)`;
`Forbidden`/ошибка → `None`. Для «чатов»-людей участников нет → `None`. Любая ошибка (приватный
канал/группа без членства) → `None` по брифу. Такие кандидаты получат метку
«участники не подтверждены» (Task 6) вместо пропуска.
- **is_forum**: берётся с entity — `Channel.forum: Optional[bool]` (флаг конструктора `channel`).
⚠️ У `ChannelFull` поля `forum` НЕТ (есть только `view_forum_as_messages` — личная настройка
просмотра, не признак форума). Fallback: если entity пришло в min-форме без флага — `False`.
- **topic_id / темы форума**: `Message.reply_to``MessageReplyHeader.reply_to_top_id` (в 1.37 поле
есть), но для раскладки «по активным темам» (спека §6) плоской ленты недостаточно — чтение по
темам через `channels.GetForumTopicsRequest` реализовано в Fix round 1 (см. ниже).
### Прочее
1. **Кэш entity из поиска**: `discovery_search` делает `client.session.process_entities(found)`
иначе `discovery_info/read` по `dialog_id` не смогут резолвить кандидата до вступления (нет в
dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта.
2. **`discovery_join` и ручной join (Task 7)** — **закрыто в Fix round 1**: пауза убрана из
`discovery_join` (ручной join — вне квот); `wait_join_delay()` перед авто-вступлением будет
вызывать воркер (Task 6).
3. **`add_dialog_monitored` не запускает backfill**: по брифу авто-логики нет (в отличие от
`set_monitor`, где `_spawn(backfill_dialog)`). Строка остаётся `backfilled=FALSE`, и разбор
последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании;
если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызвать
`tg.backfill_dialog(dialog_id)` явно (спека §7).
4. **Ошибки поиска**: пауза стоит после успешного `contacts.search`; ошибки запроса (в т.ч.
`FloodWaitError`) пробрасываются без `note_flood` (бриф связывает flood-обработку только с
join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (события `flood`/`error`).
5. **Юзеры в выдаче поиска**: `contacts.search` возвращает и людей (`_kind_of` → «чат»). По брифу
не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало
сообщений) — при желании Task 6 может отфильтровать их раньше.
6. **Pyright-«шум»** в новых методах (`Entity | List[Entity]` в `JoinChannelRequest`, отсутствие
`process_entities` в стабах сессии и т.п.) — тот же класс предупреждений, что и в существующем
коде (`backfill_dialog`, `dialog_messages`); на рантайм не влияет, код следует стилю файла.
---
## Fix round 1
Правки по итогам ревью (только `backend/app/services/telegram.py`).
### 1) Пауза убрана из `discovery_join`
- Удалён `await ban_guard.wait_join_delay()` из метода: ручной join из API (Task 7) — вне квот/пауз.
- Внутри осталась только обработка `FloodWaitError``ban_guard.note_flood()` + `raise`.
- Паузу перед авто-вступлением теперь вызывает воркер (Task 6): `await ban_guard.wait_join_delay()`
непосредственно перед `tg.discovery_join(...)`. `ban_guard` в файле по-прежнему используется
(`search_pause` в `discovery_search`, `note_flood` в `discovery_join`).
### 2) Форумные темы в `discovery_read`
- Определение форума — `entity.forum`.
- Если forum: `functions.channels.GetForumTopicsRequest(channel=entity, offset_date=0,
offset_id=0, offset_topic=0, limit=5)` → `topics`; для каждого topic читается до
`max(1, limit // len(topics))` последних сообщений через `client.get_messages(entity, limit=n,
reply_to=topic.id)` (в 1.37 это `messages.GetRepliesRequest` — см. Concerns).
- Возвращается плоский список `{id, text, date_ms, topic_id, topic_title}` (`topic_id=topic.id`,
`topic_title=topic.title`); для non-forum оба поля `None` (topic_id из `reply_to_top_id` больше
не берётся — контракт брифа).
- Безопасность: исключения в темах не пробрасываются — тема, которая не прочиталась,
пропускается; если не собрано ни одного сообщения тем — fallback на обычное чтение ленты
(General). Полный отказ и обычного чтения → `ok=False, error="no_history"`. Формат ответа
сохранён: `{"ok", "error", "messages"}`.
- Добавлены приватные хелперы: `_read_forum_topics(...)` (чтение тем) и
`_discovery_message_item(...)` (общий фильтр непустого текста + сборка item для ленты и тем).
### Проверка Fix round 1
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK
```
Офлайн-смоук без сети (`LEADRADAR_DATA=/tmp/lr_t4e`): `discovery_read` без клиента → `no_history`,
`limit<=0` → пустой `ok`; `discovery_join('')` → `ValueError`; `_discovery_message_item` фильтрует
пустой текст и собирает поля `topic_id`/`topic_title` — `SMOKE_OK`. Живых Telegram-вызовов нет.
### Concerns (Fix round 1)
1. Сигнатура `GetForumTopicsRequest` в 1.37: параметр называется `channel` (не `peer`), а
параметра `offset` нет (есть `offset_date/offset_id/offset_topic`); вызываем с реальными именами.
2. Поле темы в 1.37 — `ForumTopic.title` (не `top_title`); берём `topic.title`.
3. `messages.GetHistoryRequest` в 1.37 не имеет `top_msg_id`; `client.get_messages(...,
reply_to=topic_id)` реализован через `messages.GetRepliesRequest(peer, msg_id=topic_id)` — в
Telegram сообщения темы форума являются «ответами» на её стартовое сообщение, поэтому это
корректный способ чтения темы. Ручной fallback через `GetHistoryRequest(top_msg_id=…)` в 1.37
невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента.
4. Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на
живом аккаунте в E2E (Task 10).
---
## Fix round 2
Правка по замечанию ревью (только `backend/app/services/telegram.py`).
### Изменение
- В `_read_forum_topics` размер выборки на тему изменён с `max(1, limit // len(topics))`
на `min(max(3, math.ceil(limit / len(topics))), 10)`:
- минимум **3** сообщения на тему — иначе типичная тема не набирает порог
«мало сообщений» (Task 5: `passed` требует ≥3 содержательных) и многотемные
форумы почти всегда отсеивались бы как «мало подходящих»;
- `ceil` вместо целочисленного деления — при `limit=10` и 5 темах теперь 3, а не 2;
- cap **10** — не выкачиваем больше десятка на тему (sample_size ограничен 3..30,
чтение по темам и так дороже плоской ленты).
- Добавлен `import math`. Поведение без тем/с ошибками не менялось: пустой список тем и
исключения по-прежнему ведут к fallback на обычное чтение ленты (General); суммарная
выборка может слегка превышать `limit` — осознанно для форумов.
Примеры расчёта: limit=10/5 тем → 3 на тему; limit=10/1 тема → 10; limit=30/5 тем → 6;
limit=30/10 тем → 3.
### Проверка
```
$ cd /c/telbase && python -m py_compile backend/app/services/telegram.py
PY_COMPILE_OK # без ошибок
```
### Re-review (scoped, по текущему коду `_read_forum_topics`)
Вердикт: **ADDRESSED** — новых Critical/Important в фиксе нет.
1. **Формула и cap корректны** (L784 `min(max(3, math.ceil(limit / len(topics))), 10)`):
- limit=10 / 5 тем: `ceil(10/5)=2` → `max(3,2)=3` → `min(3,10)=3` ✓
- limit=30 / 10 тем: `ceil(3)=3` → 3 ✓
- limit=30 / 5 тем: `ceil(6)=6` → 6 ✓
Нижняя граница (3) и cap (10) на месте; `ceil` даёт int, деления на ноль нет —
`if not topics: return out` (L780–781) стоит до расчёта.
2. **Fallback и обработка ошибок не сломаны**: пустой список тем и исключение
`getForumTopicsRequest` → `return out` → в `discovery_read` пустой результат ведёт к
обычной ленте (General) (L746747); ошибка чтения отдельной темы → `continue`
(L793795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743745).
Строки fallback-путей не менялись.
3. **Новых проблем в фрагменте нет**: `import math` не конфликтует (имя `math` в модуле
ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783)
соответствует поведению.
Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное):
- `GetForumTopicsRequest` имеет `limit=5`, поэтому фактически `len(topics) ≤ 5` — случай
«30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи
тем вырастет.
- Пол «минимум 3» может заметно превышать маленький `limit` (напр. limit=3 при 5 темах →
до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочих
`sample_size` 3..30 деградации нет.