Files
Deal/archive/leadradar-legacy/backend/app/services/telegram.py
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

884 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Telegram-интеграция (п.4.2, 4.3 ТЗ).
api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия
Telethon сохраняется в файловой системе — повторная авторизация не нужна.
Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с
включённым мониторингом кладутся в очередь pipeline, откуда их разбирает
фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее
фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник».
"""
from __future__ import annotations
import asyncio
import logging
import math
import random
import threading
import time
from telethon import TelegramClient, events, utils
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError
from telethon.errors.rpcerrorlist import FloodWaitError
from telethon.tl import functions
from .. import config
from ..constants import DIALOG_HUES
from ..crypto import decrypt_text
from ..db import store
from ..sse import broker
from . import ban_guard
from . import pipeline
log = logging.getLogger("leadradar.tg")
BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан
BACKFILL_PER_DIALOG = (3.0, 6.0)
def _keys() -> dict:
raw = store.get_setting("tgKeys") or {}
api_hash = str(raw.get("apiHash", ""))
return {
"apiId": str(raw.get("apiId", "")).strip(),
"apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash),
}
# Главный event loop приложения: на нём живут Telethon-клиент и фоновые
# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running
# loop), поэтому корутины нужно планировать на этот loop через
# call_soon_threadsafe — Telethon не переносит клиент между циклами.
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
def register_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _MAIN_LOOP
_MAIN_LOOP = loop
def _spawn(coro) -> None:
"""Запустить корутину из любого контекста: running loop, главный loop или поток.
set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где
running loop отсутствует, поэтому прямой asyncio.create_task падает с
RuntimeError (отсюда Internal Server Error при включении канала).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = _MAIN_LOOP
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(loop.create_task, coro)
return
threading.Thread(
target=lambda: asyncio.new_event_loop().run_until_complete(coro),
daemon=True,
).start()
else:
loop.create_task(coro)
class TelegramManager:
def __init__(self) -> None:
self.client: TelegramClient | None = None
self.phase = "idle" # idle | phone | code | password | qr | ready
self.phone: str = ""
self._code_hash: str = ""
self._auth_lock = asyncio.Lock()
self._listener_task: asyncio.Task | None = None
self._monitored: set[str] = set()
self.error: str | None = None
self._disconnect_hook = None
# QR
self.qr_url: str = ""
self._qr_task: asyncio.Task | None = None
# сердцебиение статуса
self._last_connected: bool | None = None
# backfill каналов при первом подключении
self._backfilling: set[str] = set()
# ── состояние для UI ──────────────────────────────────────────────────
def status(self) -> dict:
row = store.query_one(
"SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE"
)
acc = store.get_setting("tgAccount") or ""
connected = bool(self.client and self.client.is_connected())
listener_alive = bool(self._listener_task and not self._listener_task.done())
return {
"phase": self.phase,
"connected": connected,
"listener": listener_alive,
"account": acc,
"monitored": int(row["d"]) if row else 0,
"keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")),
"error": self.error,
"qrUrl": self.qr_url if self.phase == "qr" else None,
}
def _client(self) -> TelegramClient:
keys = _keys()
api_id = str(keys.get("apiId") or "").strip()
api_hash = str(keys.get("apiHash") or "").strip()
if not api_id or not api_hash:
raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках")
if self.client is None:
session = str(config.SESSIONS_DIR / config.SESSION_PREFIX)
self.client = TelegramClient(session, int(api_id), api_hash)
return self.client
# ── веб-авторизация ───────────────────────────────────────────────────
async def start_phone(self, phone: str) -> None:
async with self._auth_lock:
self.phone = phone
self.error = None
try:
client = self._client()
await client.connect()
sent = await client.send_code_request(phone)
self._code_hash = sent.phone_code_hash
self.phase = "code"
except Exception as exc: # noqa: BLE001
self.phase = "idle"
self.error = str(exc)
raise
async def submit_code(self, code: str) -> None:
async with self._auth_lock:
self.error = None
client = self._client()
try:
await client.sign_in(self.phone, code, phone_code_hash=self._code_hash)
await self._finalize()
except SessionPasswordNeededError:
self.phase = "password"
except PhoneCodeInvalidError:
self.error = "Неверный код"
raise ValueError("Неверный код")
except PhoneCodeExpiredError:
self.error = "Код истёк — запросите новый"
raise ValueError("Код истёк — запросите новый")
except Exception as exc: # noqa: BLE001
self.error = str(exc)
raise
async def submit_password(self, password: str) -> None:
async with self._auth_lock:
self.error = None
try:
await self.client.sign_in(password=password)
await self._finalize()
except Exception as exc: # noqa: BLE001
self.error = "Неверный облачный пароль"
raise ValueError("Неверный облачный пароль") from exc
async def _finalize(self, notify: bool = True) -> None:
me = await self.client.get_me()
store.set_setting("tgAccount", f"@{me.username or 'user'}")
self.phase = "ready"
self._start_listener()
await self.refresh_dialogs()
self._schedule_first_backfill()
if notify:
await broker.publish_toast("Telegram подключён, сессия сохранена", "send")
await self._publish_status()
async def disconnect(self) -> None:
async with self._auth_lock:
if self._qr_task:
self._qr_task.cancel()
self._qr_task = None
if self._listener_task:
self._listener_task.cancel()
self._listener_task = None
if self.client:
try:
await self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.phase = "idle"
self._monitored.clear()
self.qr_url = ""
store.set_setting("tgAccount", "")
await broker.publish_toast("Telegram отключён", "logout")
await self._publish_status()
async def auto_resume(self) -> None:
"""При старте сервера: если сессия сохранена — подключиться автоматически."""
try:
keys = _keys()
if not (keys.get("apiId") and keys.get("apiHash")):
return
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
except Exception as exc: # noqa: BLE001
log.info("auto_resume skipped: %s", exc)
self.phase = "idle"
# ── прослушивание ─────────────────────────────────────────────────────
def _start_listener(self) -> None:
if self._listener_task and not self._listener_task.done():
return
self._reload_monitored()
self.client.add_event_handler(self._on_message, events.NewMessage())
self._listener_task = asyncio.create_task(self.client.run_until_disconnected())
def _on_done(task: asyncio.Task) -> None:
if task.cancelled():
log.info("listener stopped (cancelled)")
return
exc = task.exception()
if exc:
log.error("listener crashed: %s", exc)
else:
log.info("listener stopped")
self._listener_task.add_done_callback(_on_done)
def _reload_monitored(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
self._monitored = {r["id"] for r in rows}
def _dialog_id(self, message) -> str:
try:
chat_id = message.chat_id
except Exception: # noqa: BLE001
chat_id = message.peer_id
return str(chat_id)
async def _on_message(self, event) -> None:
"""Каждое сообщение мониторящегося диалога кладём в очередь pipeline."""
try:
msg = event.message
if msg is None or msg.text is None or not msg.text.strip():
return
dialog_id = self._dialog_id(event.message)
if dialog_id not in self._monitored:
return
chat = await event.get_chat()
ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
ch_handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, ch_name)
now = time.time_ns() // 1_000_000
ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now
store.execute(
"UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?",
[msg.text.strip()[:200], now, now, dialog_id],
)
pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts)
# помечаем сообщение прочитанным в Telegram, чтобы оно не висело
# «новым» в других клиентах/устройствах
try:
await self.client.send_read_acknowledge(chat)
except Exception: # noqa: BLE001
log.debug("read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.exception("on_message failed: %s", exc)
# ── QR-вход ───────────────────────────────────────────────────────────
async def qr_start(self) -> str:
async with self._auth_lock:
self.error = None
client = self._client()
await client.connect()
if await client.is_user_authorized():
await self._finalize(notify=False)
return ""
if self._qr_task and not self._qr_task.done():
return self.qr_url
qr = await client.qr_login()
self.qr_url = qr.url
self.phase = "qr"
self._qr_task = asyncio.create_task(self._wait_qr(qr))
return self.qr_url
async def _wait_qr(self, qr) -> None:
try:
await qr.wait()
await self._finalize(notify=True)
except Exception as exc: # noqa: BLE001
log.warning("qr wait error: %s", exc)
self.error = str(exc)
self.phase = "idle"
finally:
self._qr_task = None
# ── статус (сердцебиение) ─────────────────────────────────────────────
async def _publish_status(self) -> None:
await broker.publish("system_status", self.status())
async def heartbeat(self) -> None:
"""Периодический вызов из планировщика: уведомляем, если Telegram «уснул»."""
connected = bool(self.client and self.client.is_connected())
if self.phase == "ready" and connected != self._last_connected:
self._last_connected = connected
await self._publish_status()
if not connected:
await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell")
if self.phase == "ready" and connected:
self._last_connected = True
# ── backfill: при первом подключении по 10 последних сообщений ────────
def _schedule_first_backfill(self) -> None:
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE")
ids = [r["id"] for r in rows]
if ids:
asyncio.create_task(self._backfill_dialogs(ids))
async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None:
for dialog_id in dialog_ids:
if dialog_id in self._backfilling:
continue
try:
processed = await self.backfill_dialog(dialog_id, force=force)
if processed:
log.info("backfill %s: %d сообщений в очередь", dialog_id, processed)
except Exception as exc: # noqa: BLE001
log.warning("backfill %s failed: %s", dialog_id, exc)
await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG))
async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int:
"""Последние 10 сообщений канала: разбираем с паузами (анти-бан).
force=True — «Перечитать» по кнопке: даже если канал уже разобран.
Повторы карточек не создаются (защита dedup по нормализованному тексту).
"""
if dialog_id in self._backfilling:
return 0
client = self.client
if not client or not client.is_connected():
return 0
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if not row or (not force and bool(row["backfilled"])):
return 0
self._backfilling.add(dialog_id)
processed = 0
try:
entity = await client.get_entity(int(dialog_id))
chat = await client.get_entity(int(dialog_id))
name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id
handle = getattr(chat, "username", "") or ""
hue = dialog_hue(dialog_id, name)
msgs = await client.get_messages(entity, limit=10)
for m in reversed(msgs): # от старых к новым, как реальный поток
if m.text is None or not m.text.strip():
continue
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if m.date else now
# в очередь уходит всё; отсев сделает воркер, в БД осядет только
# то, что прошло фильтры
pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts)
processed += 1
await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE))
# «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("backfill read ack failed", exc_info=True)
store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id])
finally:
self._backfilling.discard(dialog_id)
return processed
async def realtime_sweep(self) -> None:
"""Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с
докачиваем непрочитанные сообщения включённых каналов, кладём в очередь
и помечаем прочитанными."""
client = self.client
if not client or not client.is_connected():
return
self._reload_monitored()
if not self._monitored:
return
try:
dialogs = await client.get_dialogs(limit=500)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: get_dialogs fail: %s", exc)
return
# синхронизация списка: новые каналы/группы появляются и включаются
# автоматически, удалённые исчезают (см. _persist_dialogs)
try:
entries = []
for dlg in dialogs:
ent = dlg.entity
name = dlg.name or ""
entries.append(
(
str(dlg.id),
name,
getattr(ent, "username", "") or "",
self._kind_of(ent),
dialog_hue(str(dlg.id), name),
)
)
if entries:
self._persist_dialogs(entries)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep: sync dialogs fail: %s", exc)
for dlg in dialogs:
did = str(dlg.id)
if did not in self._monitored:
continue
unread = int(getattr(dlg, "unread_count", 0) or 0)
if unread <= 0:
continue
try:
msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10))
added = 0
for m in reversed(msgs): # от старых к новым
if m.text is None or not m.text.strip():
continue
if store.scalar(
"SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id]
):
continue
ent = dlg.entity
name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did
handle = getattr(ent, "username", "") or ""
hue = dialog_hue(did, name)
now = time.time_ns() // 1_000_000
ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now
pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts)
added += 1
if added:
log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added)
await client.send_read_acknowledge(dlg.entity)
except Exception as exc: # noqa: BLE001
log.debug("realtime sweep dialog %s fail: %s", did, exc)
# ── диалоги/каналы ────────────────────────────────────────────────────
@staticmethod
def _kind_of(entity) -> str:
if getattr(entity, "broadcast", False):
return "канал"
if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False):
return "группа"
return "чат"
def _persist_dialogs(self, entries: list[tuple]) -> int:
"""Синхронизация списка диалогов с Telegram.
- новые чаты/каналы добавляются; авто-мониторинг новых управляется
настройкой autoMonitorNew (вкл — любой появившийся чат мониторится,
выкл — появляется отключённым);
- переименования/смена типа обновляются (monitor пользователя не трогаем);
- диалоги, которых больше нет в Telegram (вышел/удалил), удаляются.
"""
if not entries:
return 0
now = time.time_ns() // 1_000_000
auto_new = bool(store.get_setting("autoMonitorNew"))
seen: set[str] = set()
for dlg_id, name, handle, kind, hue in entries:
seen.add(dlg_id)
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at",
[dlg_id, name, handle, kind, hue, auto_new, now],
)
stale = store.query(
"SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")",
list(seen),
)
if stale:
stale_ids = [r["id"] for r in stale]
store.execute(
"DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")",
stale_ids,
)
log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids))
self._reload_monitored()
return len(entries)
async def refresh_dialogs(self) -> int:
client = self._client()
if not client.is_connected():
await client.connect()
entries: list[tuple] = []
async for dialog in client.iter_dialogs(limit=500):
entity = dialog.entity
name = dialog.name or ""
handle = getattr(entity, "username", "") or ""
kind = self._kind_of(entity)
hue = dialog_hue(str(dialog.id), name)
entries.append((str(dialog.id), name, handle, kind, hue))
if entries:
self._persist_dialogs(entries)
return len(entries)
def list_dialogs(self) -> list[dict]:
rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name")
return [
{
"id": r["id"],
"name": r["name"],
"handle": r["handle"],
"type": r["kind"],
"hue": r["hue"],
"on": bool(r["monitor"]),
"last": {"text": r["last_text"], "time": r["last_at"]},
}
for r in rows
]
def set_monitor(self, dialog_id: str, enabled: bool) -> None:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, time.time_ns() // 1_000_000, dialog_id],
)
self._reload_monitored()
if enabled:
row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id])
if row and not bool(row["backfilled"]):
# первое включение мониторинга: разбираем последние 10 сообщений с паузами
_spawn(self.backfill_dialog(dialog_id))
def set_monitor_all(self, enabled: bool) -> int:
"""Включить/выключить мониторинг сразу для всех диалогов.
Первое включение каждого канала разбирается последовательно (с паузами),
чтобы не попасть под бан Telegram.
"""
now = time.time_ns() // 1_000_000
rows = store.query("SELECT id, backfilled FROM dialogs")
to_backfill: list[str] = []
for r in rows:
store.execute(
"UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?",
[enabled, now, r["id"]],
)
if enabled and not bool(r["backfilled"]):
to_backfill.append(r["id"])
self._reload_monitored()
if enabled and to_backfill:
_spawn(self._backfill_dialogs(to_backfill))
return len(rows)
def backfill_monitored(self) -> int:
"""Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов.
Разбор идёт в фоне последовательно с паузами (анти-бан), даже если
канал уже разобран (force). Включённые каналы продолжают ловить новые
сообщения в реальном времени — это ручная догонялка.
"""
rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE")
ids = [r["id"] for r in rows]
if not ids:
return 0
_spawn(self._backfill_dialogs(ids, force=True))
return len(ids)
async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]:
"""Последние сообщения диалога: свежие берём из Telegram, старые — из БД."""
out: list[dict] = []
client = self.client
try:
if client and client.is_connected():
entity = await client.get_entity(int(dialog_id))
msgs = await client.get_messages(entity, limit=min(limit, 30))
now = time.time_ns() // 1_000_000
for m in msgs:
if m.text:
row = store.query_one(
"SELECT id FROM messages WHERE id = ?",
[f"m_{dialog_id}_{m.id}"],
)
lead_id = None
if row:
lead_id = row.get("lead_id") or None
if not row:
store.execute(
"INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)",
[f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now],
)
out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)})
# вручную вытащили сообщения — снимаем «новое» в Telegram
try:
await client.send_read_acknowledge(entity)
except Exception: # noqa: BLE001
log.debug("dialog_messages read ack failed", exc_info=True)
except Exception as exc: # noqa: BLE001
log.warning("dialog_messages tg fail: %s", exc)
if not out:
rows = store.query(
"SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?",
[dialog_id, limit],
)
out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows]
return out
# ── discovery: поиск каналов и действия (Task 4) ──────────────────────
async def discovery_search(self, q: str, limit: int = 30) -> list[dict]:
"""Глобальный поиск каналов/групп по ключу (contacts.search).
id возвращается подписанным (как в dialogs: каналы -100…, группы -id,
люди +id). Найденные entity кэшируются в сессию Telethon — тогда
discovery_info/read смогут получить участников/историю по id даже без
вступления (публичные источники).
"""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
found = await client(functions.contacts.SearchRequest(q=q, limit=limit))
# пауза между поисковыми запросами (анти-бан, BanGuard)
await asyncio.sleep(ban_guard.search_pause())
try:
client.session.process_entities(found)
except Exception: # noqa: BLE001
log.debug("discovery_search: cache entities failed", exc_info=True)
out: list[dict] = []
seen: set[str] = set()
for ent in (*found.chats, *found.users):
try:
dialog_id = str(utils.get_peer_id(ent))
except (TypeError, ValueError):
continue
if dialog_id in seen:
continue
seen.add(dialog_id)
name = utils.get_display_name(ent) or dialog_id
out.append(
{
"id": dialog_id,
"name": name,
"username": getattr(ent, "username", "") or "",
"kind": self._kind_of(ent),
"hue": dialog_hue(dialog_id, name),
}
)
if len(out) >= limit:
break
return out
async def discovery_info(self, dialog_id: str) -> dict:
"""Инфо об источнике: тип, username, участники, признак форума.
participants берётся из full_chat (channels.getFullChannel для
каналов/супергрупп, messages.getFullChat для базовых групп); если
определить не удалось (нет членства/приватный/ошибка) — None,
исключение наружу не бросаем.
"""
result = {
"id": str(dialog_id),
"name": str(dialog_id),
"username": "",
"kind": "",
"hue": dialog_hue(str(dialog_id), str(dialog_id)),
"participants": None,
"is_forum": False,
}
client = self.client
if not client or not client.is_connected():
return result
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc)
return result
name = utils.get_display_name(entity) or str(dialog_id)
kind = self._kind_of(entity)
result.update(
name=name,
username=getattr(entity, "username", "") or "",
kind=kind,
hue=dialog_hue(str(dialog_id), name),
is_forum=bool(getattr(entity, "forum", False)),
)
try:
if kind in ("канал", "группа"):
full = await client(functions.channels.GetFullChannelRequest(entity))
full_chat = full.full_chat
participants = int(getattr(full_chat, "participants_count", 0) or 0)
result["participants"] = participants or None
elif getattr(entity, "title", None):
# базовая группа (legacy): полный чат приносит список участников
full = await client(functions.messages.GetFullChatRequest(entity.id))
members = getattr(
getattr(full.full_chat, "participants", None), "participants", None
)
if members:
result["participants"] = len(members)
except Exception as exc: # noqa: BLE001
log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc)
return result
async def discovery_read(self, dialog_id: str, limit: int) -> dict:
"""Последние сообщения источника для оценки (без создания карточек).
Форум (entity.forum): читаем выборку по активным темам
(channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id))
и возвращаем плоский список с topic_id/topic_title. Для обычных
источников topic_id/topic_title = None. История недоступна
(приватный/закрытый источник без членства) — ok=False,
error="no_history". Исключения наружу не бросаем: ошибка в темах —
безопасный fallback на обычное чтение ленты.
"""
client = self.client
limit = max(int(limit or 0), 0)
if limit <= 0:
return {"ok": True, "error": None, "messages": []}
if not client or not client.is_connected():
return {"ok": False, "error": "no_history", "messages": []}
try:
entity = await client.get_entity(int(dialog_id))
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
if getattr(entity, "forum", False):
try:
topic_msgs = await self._read_forum_topics(client, entity, limit)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc)
topic_msgs = []
if topic_msgs:
return {"ok": True, "error": None, "messages": topic_msgs}
# обычная лента (каналы/группы; для форума — General-тема)
try:
msgs = await client.get_messages(entity, limit=limit)
except Exception as exc: # noqa: BLE001
log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc)
return {"ok": False, "error": "no_history", "messages": []}
now = time.time_ns() // 1_000_000
out: list[dict] = []
for m in msgs:
item = self._discovery_message_item(m, None, None, now)
if item:
out.append(item)
return {"ok": True, "error": None, "messages": out}
async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]:
"""Выборка сообщений по активным темам форума.
Возвращает плоский список {id, text, date_ms, topic_id, topic_title}.
Исключения не бросает: темы, которые не прочитались, пропускаются,
вызывающий решает, делать ли fallback на обычную ленту.
"""
out: list[dict] = []
try:
res = await client(
functions.channels.GetForumTopicsRequest(
channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5
)
)
topics = list(getattr(res, "topics", None) or [])
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: getForumTopics fail: %s", exc)
return out
if not topics:
return out
# на тему минимум 3 сообщения (иначе тема почти всегда отсеется как
# «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit
per_topic = min(max(3, math.ceil(limit / len(topics))), 10)
now = time.time_ns() // 1_000_000
for topic in topics:
topic_id = int(getattr(topic, "id", 0) or 0)
topic_title = getattr(topic, "title", "") or ""
if not topic_id:
continue
try:
msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id)
except Exception as exc: # noqa: BLE001
log.debug("discovery_read: topic %s read fail: %s", topic_id, exc)
continue
for m in msgs:
item = self._discovery_message_item(m, topic_id, topic_title, now)
if item:
out.append(item)
return out
@staticmethod
def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None:
"""Одно сообщение discovery_read: только непустой текст (как в
backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}."""
text = getattr(m, "text", None)
if not text or not text.strip():
return None
date = getattr(m, "date", None)
return {
"id": m.id,
"text": text,
"date_ms": int(date.timestamp() * 1000) if date else now,
"topic_id": topic_id,
"topic_title": topic_title,
}
async def discovery_join(self, username: str) -> None:
"""Вступить в канал/группу по @username (channels.JoinChannelRequest).
Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением
делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется
в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается
вызывающему.
"""
username = (username or "").strip().lstrip("@")
if not username:
raise ValueError("Не указан username для вступления")
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
try:
entity = await client.get_entity(username)
await client(functions.channels.JoinChannelRequest(entity))
except FloodWaitError:
ban_guard.note_flood()
log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username)
raise
log.info("discovery_join: вступили в @%s", username)
async def discovery_leave(self, dialog_id: str) -> None:
"""Выйти из канала/группы (channels.LeaveChannelRequest)."""
client = self.client
if not client or not client.is_connected():
raise RuntimeError("Telegram не подключён")
entity = await client.get_entity(int(dialog_id))
await client(functions.channels.LeaveChannelRequest(entity))
log.info("discovery_leave: вышли из %s", dialog_id)
def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None:
"""Добавить источник в dialogs с monitor=TRUE (после вступления).
INSERT/UPDATE без авто-логики (как set_monitor, но без запуска
backfill): backfilled=FALSE — разбор последних сообщений подхватит
обычный механизм при первом подключении/перечитывании.
"""
now = time.time_ns() // 1_000_000
store.execute(
"INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) "
"VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) "
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, "
"kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, "
"updated_at = excluded.updated_at",
[
str(dialog_id),
name or str(dialog_id),
username or "",
kind or "",
hue or "#666",
now,
],
)
self._reload_monitored()
def dialog_hue(dialog_id: str, name: str = "") -> str:
h = 0
for ch in (dialog_id + name):
h = (h * 31 + ord(ch)) % len(DIALOG_HUES)
return DIALOG_HUES[h]
tg = TelegramManager()