Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
from pydantic import BaseModel
|
|
|
|
from ..auth import current_login
|
|
from ..services.telegram import tg
|
|
|
|
router = APIRouter(prefix="/api/tg", tags=["telegram"])
|
|
|
|
|
|
def _qr_svg(url: str) -> str:
|
|
"""Реальный QR-код (SVG, без внешних растровых зависимостей)."""
|
|
import io
|
|
|
|
import qrcode
|
|
import qrcode.image.svg
|
|
|
|
qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
|
qr.add_data(url)
|
|
qr.make(fit=True)
|
|
buf = io.StringIO()
|
|
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
@router.get("/qr-image")
|
|
def qr_image(_: str = Depends(current_login)) -> Response:
|
|
"""SVG-картинка QR для сканирования (фаза входа 'qr')."""
|
|
if tg.phase != "qr" or not tg.qr_url:
|
|
raise HTTPException(404, "QR не активен — начните вход по QR")
|
|
return Response(
|
|
_qr_svg(tg.qr_url),
|
|
media_type="image/svg+xml",
|
|
headers={"Cache-Control": "no-store", "Content-Disposition": "inline"},
|
|
)
|
|
|
|
|
|
class PhoneBody(BaseModel):
|
|
phone: str
|
|
|
|
|
|
class CodeBody(BaseModel):
|
|
code: str
|
|
|
|
|
|
class PasswordBody(BaseModel):
|
|
password: str
|
|
|
|
|
|
class MonitorBody(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class PreviewBody(BaseModel):
|
|
dialogId: str
|
|
limit: int = 24
|
|
|
|
|
|
@router.get("/status")
|
|
def status(_: str = Depends(current_login)) -> dict:
|
|
return tg.status()
|
|
|
|
|
|
@router.post("/start-phone")
|
|
async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict:
|
|
try:
|
|
await tg.start_phone(body.phone.strip())
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
|
return {"phase": tg.phase}
|
|
|
|
|
|
@router.post("/start-qr")
|
|
async def start_qr(_: str = Depends(current_login)) -> dict:
|
|
try:
|
|
url = await tg.qr_start()
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
|
return {"phase": tg.phase, "qrUrl": url}
|
|
|
|
|
|
@router.post("/send-code")
|
|
async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict:
|
|
try:
|
|
await tg.submit_code(body.code.strip())
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(400, tg.error or str(exc)) from exc
|
|
return {"phase": tg.phase}
|
|
|
|
|
|
@router.post("/send-password")
|
|
async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict:
|
|
try:
|
|
await tg.submit_password(body.password)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
return {"phase": tg.phase}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(_: str = Depends(current_login)) -> dict:
|
|
await tg.disconnect()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/dialogs")
|
|
def dialogs(_: str = Depends(current_login)) -> dict:
|
|
return {"items": tg.list_dialogs()}
|
|
|
|
|
|
@router.post("/dialogs/refresh")
|
|
async def dialogs_refresh(_: str = Depends(current_login)) -> dict:
|
|
if not tg.client or not tg.client.is_connected():
|
|
return {"ok": False, "reason": "not-connected", "count": 0}
|
|
count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60)
|
|
return {"ok": True, "count": count}
|
|
|
|
|
|
@router.post("/dialogs/monitor-all")
|
|
def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict:
|
|
"""Включить/выключить мониторинг сразу для всех каналов."""
|
|
count = tg.set_monitor_all(body.enabled)
|
|
return {"ok": True, "count": count, "enabled": body.enabled}
|
|
|
|
|
|
@router.post("/dialogs/backfill-all")
|
|
def backfill_all(_: str = Depends(current_login)) -> dict:
|
|
"""Перечитать последние 10 сообщений всех включённых каналов (кнопка)."""
|
|
count = tg.backfill_monitored()
|
|
return {"ok": True, "count": count}
|
|
|
|
|
|
@router.post("/dialogs/{dialog_id}/monitor")
|
|
def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict:
|
|
tg.set_monitor(dialog_id, body.enabled)
|
|
return {"ok": True, "enabled": body.enabled}
|
|
|
|
|
|
@router.post("/dialogs/{dialog_id}/backfill")
|
|
async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict:
|
|
processed = await tg.backfill_dialog(dialog_id)
|
|
return {"ok": True, "processed": processed}
|
|
|
|
|
|
@router.post("/dialogs/preview")
|
|
async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict:
|
|
items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50))
|
|
return {"items": items}
|