ci / build-test (push) Canceled after 0s
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 зелёные.
203 lines
17 KiB
Markdown
203 lines
17 KiB
Markdown
# 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) (L746–747); ошибка чтения отдельной темы → `continue`
|
||
(L793–795); сам вызов `_read_forum_topics` дополнительно обёрнут catch-all (L743–745).
|
||
Строки 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 деградации нет.
|