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

17 KiB
Raw Blame History

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).
    • nameutils.get_display_name(entity) (как Dialog.name), kind_kind_of, huedialog_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) -> Noneget_entity(username) + JoinChannelRequest; FloodWaitErrorban_guard.note_flood() + raise. Пауза/квоты НЕ внутри — ручной join из API вне квот, wait_join_delay() перед авто-вступлением вызывает воркер (Task 6). Пустой username → ValueError. (правка — в «Fix round 1»)
  • discovery_leave(dialog_id) -> NoneLeaveChannelRequest.
  • 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_guardIMPORT_OK, методы присутствуют;
  • офлайн-сценарии: discovery_search без клиента → RuntimeError; discovery_info без клиента → словарь с participants=None, is_forum=False; discovery_readno_historyok=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_toMessageReplyHeader.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) — вне квот/пауз.
  • Внутри осталась только обработка FloodWaitErrorban_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_titleSMOKE_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)=2max(3,2)=3min(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 и обработка ошибок не сломаны: пустой список тем и исключение getForumTopicsRequestreturn 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 деградации нет.