"""Исходящие уведомления через 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)