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 зелёные.
17 KiB
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 (см. ниже).
Прочее
- Кэш entity из поиска:
discovery_searchделаетclient.session.process_entities(found)— иначеdiscovery_info/readпоdialog_idне смогут резолвить кандидата до вступления (нет в dialogs). Запись идёт в файл сессии Telethon, работает между вызовами и после рестарта. discovery_joinи ручной join (Task 7) — закрыто в Fix round 1: пауза убрана изdiscovery_join(ручной join — вне квот);wait_join_delay()перед авто-вступлением будет вызывать воркер (Task 6).add_dialog_monitoredне запускает backfill: по брифу авто-логики нет (в отличие отset_monitor, где_spawn(backfill_dialog)). Строка остаётсяbackfilled=FALSE, и разбор последних ~10 сообщений подхватит обычный механизм при первом подключении/перечитывании; если нужен немедленный backfill после вступления — воркеру Task 6 стоит вызватьtg.backfill_dialog(dialog_id)явно (спека §7).- Ошибки поиска: пауза стоит после успешного
contacts.search; ошибки запроса (в т.ч.FloodWaitError) пробрасываются безnote_flood(бриф связывает flood-обработку только с join). Воркеру Task 6 нужно ловить RPC-ошибки поиска и логировать (событияflood/error). - Юзеры в выдаче поиска:
contacts.searchвозвращает и людей (_kind_of→ «чат»). По брифу не фильтровал; такие кандидаты обычно отсеиваются на оценке (история недоступна/мало сообщений) — при желании Task 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)
- Сигнатура
GetForumTopicsRequestв 1.37: параметр называетсяchannel(неpeer), а параметраoffsetнет (естьoffset_date/offset_id/offset_topic); вызываем с реальными именами. - Поле темы в 1.37 —
ForumTopic.title(неtop_title); берёмtopic.title. 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 невозможен; при ошибке чтения темы — пропуск темы + (при пустом результате) обычная лента.- Поведение чтения тем (полнота выборки, название темы для не-участника форума) проверить на живом аккаунте в 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, чтение по темам и так дороже плоской ленты).
- минимум 3 сообщения на тему — иначе типичная тема не набирает порог
«мало сообщений» (Task 5:
- Добавлен
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 в фиксе нет.
- Формула и 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) стоит до расчёта.
- limit=10 / 5 тем:
- Fallback и обработка ошибок не сломаны: пустой список тем и исключение
getForumTopicsRequest→return out→ вdiscovery_readпустой результат ведёт к обычной ленте (General) (L746–747); ошибка чтения отдельной темы →continue(L793–795); сам вызов_read_forum_topicsдополнительно обёрнут catch-all (L743–745). Строки fallback-путей не менялись. - Новых проблем в фрагменте нет:
import mathне конфликтует (имяmathв модуле ничем не перекрыто); остальные строки хелпера не изменены. Комментарий (L782–783) соответствует поведению.
Не-блокирующие наблюдения (вне объёма фикса, поведение осознанное):
GetForumTopicsRequestимеетlimit=5, поэтому фактическиlen(topics) ≤ 5— случай «30/10 тем» сейчас недостижим, но формула корректно его обработает, если лимит выдачи тем вырастет.- Пол «минимум 3» может заметно превышать маленький
limit(напр. limit=3 при 5 темах → до 15 сообщений вместо 3) — заявлено в комментарии как осознанное; при рабочихsample_size3..30 деградации нет.