"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/ `alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в stdout и никуда их не доставляет — аудит на проде 2026-08-15). GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`) умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` — "General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую ``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload (text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization, ни подписи, ни X-*). Значит: 1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ — ``{"text": str, "attachments": [{"title","title_link","text","color", "fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``; 2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки). Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить нечем (GlitchTip-сторона его не добавляет). Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213 defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же ``secrets.compare_digest`` constant-time compare, тот же env. Отличие от rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml). ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ несконфигурированный Telegram-бот → 503 "не настроено", а не тихий fail-open настежь. Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в ``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только значение query-параметра. Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт ``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared`` (алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile). Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``, иначе имя ``tradein-backend`` не резолвится — общей сети нет. """ from __future__ import annotations import json import logging import secrets from datetime import UTC, datetime from typing import Annotated, Any from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, ValidationError from app.core.config import settings from app.services.tgbot.client import TelegramApiError, TelegramClient logger = logging.getLogger(__name__) router = APIRouter() # Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH # для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот # же потолок). Суффикс обрезки учтён в _truncate. _TELEGRAM_MAX_LEN = 4096 _TRUNCATE_SUFFIX = "\n… (обрезано)" # Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в # support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp # timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. _INTERACTIVE_SEND_TIMEOUT_S = 8.0 _INTERACTIVE_SEND_MAX_RETRIES = 1 class GlitchTipAttachment(BaseModel): """Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны, ``extra="allow"`` на случай будущих версий GlitchTip.""" model_config = ConfigDict(extra="allow") title: str | None = None title_link: str | None = None text: str | None = None color: str | None = None fields: list[dict[str, Any]] | None = None class GlitchTipWebhookPayload(BaseModel): """Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и uptime-алертов (см. docstring модуля).""" model_config = ConfigDict(extra="allow") text: str | None = None attachments: list[GlitchTipAttachment] | None = None def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str: if len(text) <= limit: return text return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None: """Ищет значение поля attachment.fields по title (issue-алерты кладут туда "Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook).""" for field in attachment.fields or []: if str(field.get("title", "")).strip().lower() == label.lower(): value = field.get("value") return str(value) if value is not None else None return None def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str: lines = [f"GlitchTip: {payload.text or 'Alert'}"] for attachment in payload.attachments or []: block: list[str] = [] project = _field_value(attachment, "Project") if project: block.append(f"Проект: {project}") if attachment.title: block.append(attachment.title) if attachment.text: block.append(attachment.text) if attachment.title_link: block.append(f"Ссылка: {attachment.title_link}") if block: lines.append("") lines.extend(block) lines.append("") lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC") return "\n".join(lines) def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str: """Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`, ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос, пересылаем как есть с пометкой (см. требование задачи: неизвестная форма payload не должна давать 500).""" text_repr = raw_body.decode("utf-8", errors="replace") header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть" return _truncate( f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC" ) def _build_message(raw_body: bytes, received_at: datetime) -> str: try: data = json.loads(raw_body) except (json.JSONDecodeError, UnicodeDecodeError): return _format_unknown_payload(raw_body, received_at) if not isinstance(data, dict): return _format_unknown_payload(raw_body, received_at) try: payload = GlitchTipWebhookPayload.model_validate(data) except ValidationError: return _format_unknown_payload(raw_body, received_at) if payload.text is None and not payload.attachments: return _format_unknown_payload(raw_body, received_at) return _truncate(_format_known_payload(payload, received_at)) def _alerts_configured() -> bool: """Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open.""" return bool( settings.tradein_internal_auth_secret and settings.telegram_bot_token and settings.telegram_alerts_chat_id ) def _verify_secret(provided: str) -> None: expected = settings.tradein_internal_auth_secret if not secrets.compare_digest(provided or "", expected): logger.warning("glitchtip webhook: invalid or missing secret query param") raise HTTPException(status_code=401, detail="invalid or missing secret") @router.post("/ops/glitchtip-webhook") async def glitchtip_webhook( request: Request, secret: Annotated[str, Query()] = "", ) -> dict[str, str]: """Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID`` — ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля). Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот хендлер сам делает единственную проверку (``secret`` query-параметр). """ if not _alerts_configured(): raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured") _verify_secret(secret) raw_body = await request.body() received_at = datetime.now(UTC) text = _build_message(raw_body, received_at) client = TelegramClient(settings.telegram_bot_token) try: await client.send_message( chat_id=settings.telegram_alerts_chat_id, text=text, message_thread_id=settings.telegram_alerts_topic_id or None, # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не # может легально висеть воркерные минуты ретраев. timeout=_INTERACTIVE_SEND_TIMEOUT_S, max_retries=_INTERACTIVE_SEND_MAX_RETRIES, ) except TelegramApiError: logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram") raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None return {"status": "ok"}