Deal — единая кодовая база
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 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,202 @@
# 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 деградации нет.