Newer
Older
tgclient-mcp / backend / app / synapse_report.py
"""Исходящие уведомления через Gnexus Synapse (handbook notifications.md).

Тонкий fire-and-forget репортер (референс: hard-panel events.py, клиент
gn-synapse-client-py): свой ретрай не строим — доставку маршрутизирует Synapse.
Конфиг только env (TGCLIENT_SYNAPSE_*); пустой api_key — интеграция выключена,
код ничего не блокирует и не логирует спамом.

Каталог событий tgclient-mcp (subject/action — для маршрутизации Synapse,
payload.user_id = sub gnexus-auth — конвенция адресации):

| событие              | subject/action        | prio   | dedup    | где эмитится |
|----------------------|-----------------------|--------|----------|--------------|
| tg_account_logged_in | account/logged_in     | high   | д.+акк   | login_flow._finalize (новая и повторная сессия) |
| tg_account_logged_out| account/logged_out    | normal | нет      | DELETE /accounts/{id} (логаут) |
| tg_account_auth_lost | account/auth_lost     | high   | д.+акк   | manager.note_auth_lost (MTProto-ключ умер) |
| tg_login_failed      | account/login_failed  | normal | д.+login | login_flow: 3 кода/2 пароля — логин отменён |
| tg_flood_wait        | api/flood_wait        | high   | д.+акк   | call_with_flood_guard (длинный FloodWait) |
| mcp_token_issued     | token/issued          | normal | нет      | POST /me/mcp_tokens (plaintext НЕ уходит) |
| mcp_token_revoked    | token/revoked         | normal | нет      | ревок (свой и админский) |

В конверте нет получателей/каналов/топиков — кому доставить, решает
маршрутизация Synapse. Secrets: plaintext mcp_* только в ответе 201 и
gnexus-creds, никогда в payload.
"""

import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone

from app.config import get_settings

TTL_SECONDS = 24 * 3600


@dataclass(frozen=True)
class Sink:
    subject: str
    action: str
    priority: str  # low | normal | high | critical
    dedup: bool    # повторные открытия — dedup по типу+сущность+UTC-день


SINKS: dict[str, Sink] = {
    "tg_account_logged_in": Sink("account", "logged_in", "high", True),
    "tg_account_logged_out": Sink("account", "logged_out", "normal", False),
    "tg_account_auth_lost": Sink("account", "auth_lost", "high", True),
    "tg_login_failed": Sink("account", "login_failed", "normal", True),
    "tg_flood_wait": Sink("api", "flood_wait", "high", True),
    "mcp_token_issued": Sink("token", "issued", "normal", False),
    "mcp_token_revoked": Sink("token", "revoked", "normal", False),
}

_client = None
_warned_disabled = False


def synapse_client():
    global _client, _warned_disabled
    if _client is not None:
        return _client
    settings = get_settings()
    if not settings.synapse_api_key or not settings.synapse_url:
        if not _warned_disabled:
            print("synapse reporter disabled (TGCLIENT_SYNAPSE_* unset)", flush=True)
            _warned_disabled = True
        return None
    from gnexus_synapse import AsyncSynapseClient

    _client = AsyncSynapseClient(
        settings.synapse_url,
        settings.synapse_api_key,
        timeout=settings.synapse_timeout,
        default_source=settings.synapse_default_source or "tgclient-mcp",
    )
    return _client


def _utc_date() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%d")


def report(event_type: str, payload: dict) -> None:
    """Конверт v1 в Synapse, fire-and-forget: падения доставки не роняют код.

    Приоритет high/critical уходит send-ом (ошибки видны в логе), остальное —
    тихим emit. Отчёт по имени из SINKS; неизвестное имя — программная ошибка.
    """
    sink = SINKS.get(event_type)
    if sink is None:
        raise ValueError(f"unknown synapse sink: {event_type}")
    client = synapse_client()
    if client is None:
        return
    dedup = f"{event_type}-{payload.get('entity') or payload.get('user_id')}-{_utc_date()}" if sink.dedup else None
    send = client.send if sink.priority in ("high", "critical") else client.emit
    _ship(
        send(
            subject=sink.subject,
            action=sink.action,
            priority=sink.priority,
            payload=payload,
            dedup_key=dedup,
            ttl_seconds=TTL_SECONDS,
        )
    )


def _ship(coro) -> None:
    task = asyncio.create_task(coro)
    task.add_done_callback(_ship_done)


def _ship_done(task: asyncio.Task) -> None:
    if not task.cancelled() and task.exception() is not None:
        print(f"synapse report failed: {task.exception()}", flush=True)