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 зелёные.
28 KiB
Поиск и подключение каналов (Discovery) — Implementation Plan
Исторический документ (план Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние —
docs/superpowers/STATUS.mdиdocs/technical/Техническая-документация-Дейл.md.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Дать пользователю возможность создавать «задачи поиска»: система находит по описанию/ключам Telegram-каналы и группы (в которых мы не состоим), оценивает их (метаданные → язык → контент по темам), показывает «на рассмотрение», а человек вступает сам или включает авто-вступление в рамках суточных квот с анти-бан паузами.
Architecture: Дочерняя система Discovery поверх существующего стека (FastAPI + DuckDB + TelegramManager/Telethon + Vue 3). Отдельный сервис discovery (хранилище+оркестрация), новые методы Telegram-действий в TelegramManager, общий BanGuard для квот/пауз, отдельный фоновый воркер в main.py. Оценка сообщений переиспользует правила/ML/ИИ, но с профилем задачи и БЕЗ создания карточек. UI — подвкладка «Поиск» на экране «Каналы».
Tech Stack: Python 3.12 / FastAPI / DuckDB / Telethon / Vue 3 + Tailwind (Vite). Новых зависимостей нет.
Global Constraints
- Правило «мы не состоим» — глобальное и безусловное: источники из
dialogs, чёрного списка или уже в другой задаче исключаются сразу (проверка повторяется и при вступлении). - Спека:
docs/superpowers/specs/2026-09-04-channel-discovery-design.md(читать при каждом задании). - Проект НЕ git-репозиторий: вместо
git commit— проверка черезdocker compose/npm run build, фиксация результата в тексте шага. - Все тексты UI — по-русски, в стиле существующего интерфейса (без канцелярита, короткие подписи).
- Все настраиваемые числа (лимиты, паузы, пороги, размеры выборок) — настройки в БД (
store.get_setting), НЕ в коде; дефолты вconstants.DEFAULT_SETTINGS. - Новые таблицы добавлять только через
db.py(_SCHEMA,CREATE TABLE IF NOT EXISTS), при необходимости — миграции в_MIGRATIONS. - Запуск/проверка: контейнеры
docker compose up -d, бэкенд на :8000, ML на :8100; пересборкаdocker compose build app. - JSON-поля (keywords/marks/topics) хранить как VARCHAR с
json.dumps(..., ensure_ascii=False), читать черезjson.loads— как в остальном коде.
Task 1: Схема БД и настройки по умолчанию
Files:
- Modify:
backend/app/db.py(добавить 4 таблицы в_SCHEMA) - Modify:
backend/app/constants.py(DEFAULT_SETTINGS) - Modify:
backend/app/routers/settings_routes.py(_PUBLIC_INT)
Interfaces:
-
Produces: таблицы
disc_tasks,disc_candidates,disc_blacklist,disc_log; настройкиdiscJoinLimit(50),discJoinDelayMin(50),discJoinDelayMax(70),discEvalSample(10),discEvalThreshold(40). -
Step 1: Добавить таблицы в
_SCHEMA(перед таблицейsettings)
CREATE TABLE IF NOT EXISTS disc_tasks (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NOT NULL DEFAULT '',
keywords VARCHAR NOT NULL DEFAULT '[]',
min_subscribers INTEGER NOT NULL DEFAULT 0,
lang VARCHAR NOT NULL DEFAULT 'ru',
threshold INTEGER NOT NULL DEFAULT 40,
sample_size INTEGER NOT NULL DEFAULT 10,
plan_joins INTEGER NOT NULL DEFAULT 1,
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
search_idx INTEGER NOT NULL DEFAULT 0,
search_done BOOLEAN NOT NULL DEFAULT FALSE,
found INTEGER NOT NULL DEFAULT 0,
evaluated INTEGER NOT NULL DEFAULT 0,
joined INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_candidates (
dialog_id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
name VARCHAR NOT NULL DEFAULT '',
username VARCHAR NOT NULL DEFAULT '',
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
hue VARCHAR NOT NULL DEFAULT '#666',
participants INTEGER,
lang_ru BOOLEAN,
marks VARCHAR NOT NULL DEFAULT '[]',
topics VARCHAR NOT NULL DEFAULT '[]',
fit_ratio DOUBLE,
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
CREATE TABLE IF NOT EXISTS disc_blacklist (
dialog_id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL DEFAULT '',
reason VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
text VARCHAR NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
- Step 2: Добавить настройки в
constants.py→DEFAULT_SETTINGS
# поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений
- Step 3: Открыть настройки наружу в
settings_routes.py
В _PUBLIC_INT добавить discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold. В patch_settings наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.
- Step 4: Проверить
docker compose build app && docker compose up -d app
Затем GET /api/settings (после логина) — в ответе присутствуют discJoinLimit: 50 и остальные ключи. python -m py_compile всех изменённых файлов — без ошибок.
Task 2: BanGuard (квоты, паузы, flood)
Files:
- Create:
backend/app/services/ban_guard.py
Interfaces:
-
Consumes:
store, настройки из Task 1. -
Produces:
def joins_today_auto() -> int— авто-вступления за текущие UTC-сутки (считаетdisc_logevent='join_auto',created_at >= начало суток).def can_auto_join() -> bool— лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.async def wait_join_delay() -> None—asyncio.sleep(random.uniform(min, max)).def note_flood() -> None—store.set_setting("discFloodDay", <start_of_day_ms>).def flood_today() -> booldef global_paused() -> bool/def set_global_pause(v: bool) -> None(settingdiscPaused)def search_pause() -> float—random.uniform(2.0, 4.0).
-
Step 1: Реализовать модуль (~60 строк; начало суток — UTC:
datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)→ ms). -
Step 2: Проверить на временной БД в контейнере
docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init()
from app.services import ban_guard as bg
assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK')
"
Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
Files:
- Create:
backend/app/services/discovery.py
Interfaces:
-
Consumes:
store(таблицы Task 1). -
Produces (все синхронные):
list_tasks() -> list[dict],get_task(id) -> dict | None(keywords — список)create_task(payload: dict) -> dict— валидация: name непустое;plan_joins1..limit; правило бюджета:sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit, иначеraise ValueError(...).patch_task(id, patch: dict) -> dict(name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой)delete_task(id) -> None(удалить задачу и её кандидатов)start_task(id) -> dict— требует непустой keywords; status=running;pause_task(id) -> dict— pausedlist_candidates(task_id, status: str | None) -> list[dict](декод marks/topics)add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None—None, если: вdialogs, вdisc_blacklist, либо уже естьdisc_candidatesсо статусом new/review/joined. Логskipс причиной.bump_counter(task_id, field: str, n: int = 1)— found/evaluated/joined/rejectedset_candidate(task_id, dialog_id, patch: dict)— обновление полей кандидатаset_candidate_status(dialog_id, status)+ логdelete_candidate(dialog_id) -> None— удалить кандидата (skip-ветки)advance_search(task_id) -> None—search_idx += 1; когда индекс >= len(keywords) →search_done=Truemark_joined(dialog_id, auto: bool)— статус joined +bump_counter('joined')+ логjoin_auto/join_manualmark_rejected(dialog_id, reason="")— статус rejected +bump_counter('rejected')+ логreject+add_blacklistadd_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]add_log(task_id, event, text="");task_log(task_id, limit=100) -> list[dict]
-
Step 1: Реализовать модуль (json-поля по конвенции проекта; все
store.execute/queryс параметрами). -
Step 2: Проверить на временной БД (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 →
ValueError; кандидат, совпадающий сdialogs→add_candidateвернул None + лог skip;mark_rejected→ в чёрном списке; повторныйadd_candidateтого же источника → None.
Task 4: Telegram-действия поиска (методы TelegramManager)
Files:
- Modify:
backend/app/services/telegram.py(классTelegramManager)
Interfaces:
-
Consumes:
self.client,ban_guard. -
Produces (async-методы):
async def discovery_search(q: str, limit: int = 30) -> list[dict]—client(functions.contacts.SearchRequest(q=q, limit=limit)); вернуть[{id(str), name, username, kind, hue}](kind через_kind_of, hue черезdialog_hue); между вызовами —await asyncio.sleep(ban_guard.search_pause()).async def discovery_info(dialog_id: str) -> dict—{id, name, username, kind, hue, participants: int | None, is_forum: bool}(participants изfull_chatгде возможно; иначе None).async def discovery_read(dialog_id: str, limit: int) -> dict— последние сообщения:{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]};topic_id—getattr(getattr(m,'reply_to',None),'reply_to_top_id',None). История недоступна →{"ok": False, "error": "no_history", "messages": []}.async def discovery_join(username: str) -> None—client(functions.channels.JoinChannelRequest(...)); ПЕРЕД вызовомawait ban_guard.wait_join_delay();FloodWaitError→ban_guard.note_flood()и проброс.async def discovery_leave(dialog_id: str) -> None—channels.LeaveChannelRequest.def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None— INSERT/UPDATEdialogsсmonitor=TRUE, backfilled=FALSE(как вset_monitor, но без авто-join-логики).
-
Step 1: Реализовать методы (импорт
telethon.tl.functions,telethon.errors.rpcerrorlist.FloodWaitError). -
Step 2: Проверить компиляцию
py_compile. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
Task 5: Оценка контента (язык, темы, fit по профилю задачи)
Files:
- Create:
backend/app/services/discovery_eval.py
Interfaces:
-
Consumes:
store,ml_client,ai_service(chat_json),pipeline.clean_short. -
Produces:
def detect_lang_ru(texts: list[str]) -> bool | None— доля кириллических букв от всех букв в сумме:>=0.15 → True;<=0.03 → False; между порогами →None(неопределённо).def group_by_topic(messages: list[dict]) -> list[dict]— группировка поtopic_id(None → "main"); возвращает[{"topic_id", "title", "messages": [...]}], title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).async def evaluate_message(task: dict, text: str) -> dict—{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}:- текст пустой/длина <10 → fit False «слишком короткое»;
- ML: если
ml_client.is_enabled()и прогнозtakeиlabel=='spam'→ fit False «ML: спам»; - ИИ: если
aiEnabled→ один JSON-вызовai_service.chat_json(промпт, user=text)с промптом из описания задачи и ключей ({fit, reason}); ошибка → шаг 4; - эвристика: fit = любой ключ входит в
clean_short(text)casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
async def evaluate_sample(task: dict, messages: list[dict]) -> dict— последовательно по каждому сообщению; вернуть{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}.def passed(ev: dict, task: dict) -> bool—ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"].
-
Step 1: Реализовать модуль. Промпт ИИ (внутри модуля, константа):
Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}. -
Step 2: Проверить на временной БД (без сети):
detect_lang_ru(["Ищем python разработчика"]) is True;detect_lang_ru(["we need a python developer"]) is False;group_by_topicобъединяет по topic_id и сортирует;evaluate_messageна задаче без ИИ/ML возвращает эвристический fit по ключу.
Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
Files:
- Create:
backend/app/services/discovery_worker.py - Modify:
backend/app/main.py(фоновый цикл_discovery_loop, каждые 5 c)
Interfaces:
- Consumes:
discovery(Task 3),tg.discovery_*(Task 4),discovery_eval(Task 5),ban_guard(Task 2). - Produces:
async def tick() -> dict— выполняет ОДНО действие и возвращает{"action": ..., "taskId": ...}(или{"action": "none"}).
Логика tick (по одной задаче за вызов, начиная с самой старой running):
- Если задача
search_done=False: взять ключkeywords[search_idx], вызватьtg.discovery_search; для каждого результатаdiscovery.add_candidate;discovery.advance_search(task_id); еслиsearch_doneстал True — логsearch«поиск завершён: N кандидатов». Возврат. - Иначе взять первого кандидата статуса
newзадачи:info = tg.discovery_info;participants,kind(forum еслиis_forum); при заданномmin_subscribersи participants НЕ None и меньше минимума —set_candidate_status(...)нет: простоdiscovery.delete_candidate+ логskip; если participants None — метка «участники не подтверждены» (идём дальше).read = tg.discovery_read(dialog_id, sample_size).- Если
read.ok=False(история недоступна без членства): kind==channel →reviewс меткой «канал: история недоступна»; группа/форум →reviewс меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
- Если
- Язык: если прочитано и
task.lang=='ru':lang_ru=detect_lang_ru(...); False → удалить кандидата, логskip«язык не русский»; None → метка «язык не подтверждён». - Оценка:
evaluate_sample;passed→ метки topics/fit →review+ логreview; иначе удалить кандидата, логskip«мало подходящих (X из N)».
- Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи
auto_joinи есть кандидатreviewиban_guard.can_auto_join():- повторная проверка «мы не состоим» (
dialogs/blacklist) → если вступили уже →mark_rejectedс логом; await ban_guard.wait_join_delay()(рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);tg.discovery_join(username)→discovery.mark_joined(dialog_id, auto=True)→tg.add_dialog_monitored(...); при FloodWaitError →ban_guard.note_flood()+ логflood.
- повторная проверка «мы не состоим» (
- Если
task.joined >= task.plan_joins→ статусdone, логdone.
- Step 1: Реализовать
discovery_worker.pyи цикл вmain.py. - Step 2: Проверить компиляцию и запуск без падений (воркер с пустыми таблицами делает
none). Полный прогон — Task 10 вручную.
Task 7: API Discovery
Files:
- Create:
backend/app/routers/discovery_routes.py - Modify:
backend/app/main.py(регистрация роутера)
Interfaces:
- Prefix
/api/discovery, authcurrent_login:GET /tasks,POST /tasks,PATCH /tasks/{id},DELETE /tasks/{id},POST /tasks/{id}/start,POST /tasks/{id}/pausePOST /tasks/{id}/generate-keywords— ИИ: промпт по description → JSON{"keywords": [...]}(8–16 строк RU+EN); ИИ недоступен/выключен →{"keywords": [], "error": "..."}.GET /tasks/{id}/candidates?status=POST /candidates/{dialog_id}/join— ручное вступление (вне квот):tg.discovery_join+add_dialog_monitored+mark_joined(auto=False); 400 при ошибке.POST /candidates/{dialog_id}/reject—mark_rejected(добавляет в чёрный список). Если кандидат ужеjoined— 400.GET /blacklist,DELETE /blacklist/{dialog_id}GET /tasks/{id}/log
Pydantic-модели: TaskCreate (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), TaskPatch (все optional), GenKeywordsBody не нужен (id в пути).
- Step 1: Реализовать роутер (ValueError → HTTPException 400; KeyError → 404).
- Step 2: Зарегистрировать в main.py.
- Step 3: Проверить API на живом контейнере: логин, создание задачи plan=1, list, delete;
generate-keywordsвернёт error-ветку без настроенного ИИ (не падает).
Task 8: Фронтенд — store + каркас подвкладки «Поиск»
Files:
- Modify:
frontend/src/store.js - Create:
frontend/src/views/DiscoveryView.vue - Modify:
frontend/src/views/ChannelsView.vue
Interfaces:
-
state:
channelsTab: 'list' | 'search',discTasks: [],discCandidates: [],discBlacklist: [],discLog: [],discActiveTaskId: null,discCandidateStatus: 'review',discBusy: false. -
store-функции:
gotoChannelsTab(tab),loadDiscTasks(),saveDiscTask(form, id=null)(create/patch),deleteDiscTask(id),startDiscTask(id),pauseDiscTask(id),generateDiscKeywords(taskId),loadDiscCandidates(taskId, status),joinDiscCandidate(c),rejectDiscCandidate(c),loadDiscBlacklist(),removeDiscBlacklist(id),loadDiscLog(taskId). -
Step 1: store.js — состояние + функции (паттерны:
api.get/post/patch/delete,toast,errMsg). -
Step 2: ChannelsView.vue — в шапке сегмент: «Каналы | Поиск» (
state.channelsTab), содержимое по табу. -
Step 3: DiscoveryView.vue (каркас): левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
-
Step 4:
npm run build— без ошибок.
Task 9: Фронтенд — кандидаты, действия, чёрный список, история
Files:
- Modify:
frontend/src/views/DiscoveryView.vue
Interfaces:
-
Consumes: Task 8 (store).
-
Step 1: Табы панели задачи: «В обработке» (
new) / «На рассмотрении» (review) / «Вступили» (joined) / «Отклонены» (rejected) + «История» (лог). Бейджи счётчиков скрыты при 0. -
Step 2: Карточка кандидата: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
-
Step 3: Чёрный список (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с
discJoinLimit/discJoinDelayMin/discJoinDelayMax+ стоп-кран (PATCH /api/settings). -
Step 4: История — лог задачи.
-
Step 5:
npm run build— без ошибок; визуальная проверка основных сценариев (Task 10).
Task 10: ТЗ, сборка и end-to-end проверка
Files:
-
Modify:
ТЗ.md(раздел «Поиск и подключение каналов») -
Step 1: Дополнить ТЗ — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
-
Step 2: Сборка и рестарт:
docker compose build app && docker compose up -d app
cd frontend && npm run build
- Step 3: E2E вручную (нужен подключённый Telegram-аккаунт):
- «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
- Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
- Открыть кандидата: метки, участники, fit «X из N», темы форума.
- «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
- «Отклонить» → уходит в чёрный список; повторно не находится.
- Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
Self-Review
- Покрытие спеки: Task 1 (хранилище+настройки), Task 2 (квоты/анти-бан), Task 3 (задачи/бюджет планов/чёрный список), Task 4 (поиск/инфо/чтение/join), Task 5 (язык/темы/fit), Task 6 (воркер+авто-join), Task 7 (API), Task 8–9 (UI), Task 10 (ТЗ+E2E). Правило «мы не состоим» — Task 3
add_candidate, Task 6 шаг 3 (повторная проверка перед join), UI Task 9. Разделы спеки §4–§12 покрыты; «вне рамок» (§13) не реализуются. - Плейсхолдеры: нет; у каждого шага есть конкретный код/поведение и способ проверки.
- Согласованность: единые статусы задач
draft|running|paused|done|failed, кандидатовnew|review|joined|rejected; события логаsearch|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done; все имена настроек и функций совпадают между задачами.