#!/usr/bin/env python3 """Приёмник алертов с кнопкой подтверждения (#3078). ЗАЧЕМ ОТДЕЛЬНЫЙ СЕРВИС. Alertmanager умеет писать в Telegram сам, но инлайн- клавиатуру его интеграция не поддерживает — а без кнопки нет обратной связи «человек увидел и взял в работу». 27.08 продукты лежали 10 часов, и вопрос «а кто-нибудь вообще это читает» было не к кому адресовать. ПОЧЕМУ НА НАБЛЮДАТЕЛЕ, А НЕ У БОТА МЕРЫ. У `tradein-tgbot` уже есть приём обновлений, и соблазн повесить обработку туда велик. Но он живёт на продуктовом хосте: при падении продукта кнопка окажется мёртвой ровно в тот момент, ради которого её и делают. Сервис живёт рядом с Alertmanager на инфраструктурной машине и переживает смерть наблюдаемого. ПОЧЕМУ ССЫЛКА, А НЕ CALLBACK. Callback-кнопка требует, чтобы кто-то читал обновления бота. Бот один, и его обновления уже читает МЕРА — второй читатель получил бы `409 Conflict` и отобрал бы сообщения у поддержки. URL-кнопка не требует обновлений вовсе. БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека: сервис обязан подниматься даже тогда, когда всё остальное сломано, и не тащить за собой установку пакетов. Переменные окружения: METRICS_TELEGRAM_BOT_TOKEN обязательна METRICS_TELEGRAM_CHAT_ID обязательна METRICS_TELEGRAM_TOPIC_ID тема форума (необязательна) METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна) ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440) ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471, см. POST /glitchtip ниже); пусто — 503 РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend, frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel — тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy, алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip` — второй получатель того же алерта, зарегистрированный в GlitchTip отдельной строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от здоровья продукта. Формат тела — тот же Slack-совместимый payload, что и у продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`): ``{"text": str, "attachments": [{"title","title_link","text","color", "fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе — аутентификация только через секрет в query (``?secret=``) или в заголовке ``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` — секреты разных хостов/доменов безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы не спутать с основным путём. """ from __future__ import annotations import html import json import logging import os import secrets import threading import time import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("alert-ack") BOT_TOKEN = os.environ.get("METRICS_TELEGRAM_BOT_TOKEN", "") CHAT_ID = os.environ.get("METRICS_TELEGRAM_CHAT_ID", "") TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "") ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "") PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/") TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60 GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "") API = "https://api.telegram.org/bot{}/{}" # token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None} _PENDING: dict[str, dict] = {} _LOCK = threading.Lock() def _tg(method: str, payload: dict) -> dict | None: """Вызвать Bot API. Три попытки: путь до Telegram теряет соединения (#3059). Отказы происходят на стадии подключения, до отправки запроса, поэтому повтор почти никогда не дублирует уже доставленное. Потерянный алерт дороже дубля. """ data = urllib.parse.urlencode(payload).encode() for attempt in (1, 2, 3): try: req = urllib.request.Request(API.format(BOT_TOKEN, method), data=data) with urllib.request.urlopen(req, timeout=15) as resp: out = json.loads(resp.read().decode()) if attempt > 1: log.info("%s: доставлено с попытки %d", method, attempt) return out except Exception as exc: # noqa: BLE001 — падать нельзя, это канал алертов log.warning("%s: попытка %d не удалась: %s", method, attempt, exc) if attempt < 3: time.sleep(2) log.error("%s: три попытки подряд неудачны — сообщение НЕ доставлено", method) return None def _purge() -> None: """Выбросить протухшие токены. Без этого словарь растёт до перезапуска.""" now = time.time() with _LOCK: dead = [t for t, v in _PENDING.items() if now - v["created"] > TTL_SEC] for t in dead: del _PENDING[t] def _render(payload: dict) -> tuple[str, str]: """Собрать текст сообщения и краткий заголовок инцидента.""" status = payload.get("status", "firing") alerts = payload.get("alerts") or [] common = payload.get("commonLabels") or {} first = (alerts[0].get("labels") or {}).get("alertname") if alerts else "?" name = common.get("alertname") or first host = common.get("host", "") title = f"{name}{' · ' + host if host else ''}" if status == "resolved": return f"✅ Восстановлено · {html.escape(title)}", title lines = [f"🚨 КЛИЕНТЫ ЗАТРОНУТЫ · {html.escape(title)}"] if ONCALL: lines.append(f"{html.escape(ONCALL)} — нужна реакция.") for a in alerts[:5]: ann = a.get("annotations") or {} if ann.get("summary"): lines.append(html.escape(ann["summary"])) if ann.get("description"): lines.append(html.escape(ann["description"])) return "\n".join(lines), title def _send_alert(payload: dict) -> None: text, title = _render(payload) msg = { "chat_id": CHAT_ID, "text": text, "parse_mode": "HTML", "disable_web_page_preview": "true", } if TOPIC_ID: msg["message_thread_id"] = TOPIC_ID # Кнопка только у горящего инцидента: подтверждать нечего, когда всё прошло. token = "" if payload.get("status", "firing") == "firing" and PUBLIC_URL: token = secrets.token_urlsafe(16) msg["reply_markup"] = json.dumps( { "inline_keyboard": [ [{"text": "✅ Принял в работу", "url": f"{PUBLIC_URL}/ack/{token}"}] ] } ) out = _tg("sendMessage", msg) if token and out and out.get("ok"): with _LOCK: _PENDING[token] = { "message_id": out["result"]["message_id"], "title": title, "created": time.time(), "acked_by": None, } elif token: # Кнопку прицепить не вышло — сообщение важнее кнопки, шлём без неё. msg.pop("reply_markup", None) _tg("sendMessage", msg) _GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096 def _verify_glitchtip_secret(provided: str) -> bool: """Constant-time сравнение — длина/префикс секрета не утекают через время ответа (тот же приём, что у продуктового приёмника, см. docstring модуля).""" return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET) def _glitchtip_field(attachment: dict, label: str) -> str | None: for field in attachment.get("fields") or []: if not isinstance(field, dict): continue 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 _render_glitchtip(payload: dict) -> str: """Собрать текст сообщения из Slack-совместимого payload GlitchTip. Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле не считаем обязательным — падать сервису на резервном канале нельзя. """ lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"] lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.") lines.append("") text = payload.get("text") lines.append(html.escape(str(text)) if text else "GlitchTip alert") attachments = payload.get("attachments") for attachment in attachments if isinstance(attachments, list) else []: if not isinstance(attachment, dict): continue block: list[str] = [] project = _glitchtip_field(attachment, "Project") if project: block.append(f"Проект: {html.escape(project)}") if attachment.get("title"): block.append(html.escape(str(attachment["title"]))) if attachment.get("text"): block.append(html.escape(str(attachment["text"]))) if attachment.get("title_link"): block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}") if block: lines.append("") lines.extend(block) out = "\n".join(lines) if len(out) > _GLITCHTIP_TEXT_LIMIT: out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)" return out def _send_glitchtip_alert(payload: dict) -> None: """Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и Alertmanager через этот сервис. Без кнопки подтверждения — это не firing/resolved инцидент с состоянием, а разовое уведомление резервного канала.""" msg = { "chat_id": CHAT_ID, "text": _render_glitchtip(payload), "parse_mode": "HTML", "disable_web_page_preview": "true", } if TOPIC_ID: msg["message_thread_id"] = TOPIC_ID _tg("sendMessage", msg) _PAGE = ( "" "{t}" "" "

{h}

{p}

" ) def do_ack(token: str) -> tuple[int, str]: """Обработать нажатие кнопки. Вынесено из HTTP-слоя, чтобы быть проверяемым. Возвращает (код ответа, HTML-страница). Идемпотентно: повторное нажатие не шлёт второе сообщение в чат — ссылка живёт сутки, и по ней могут кликнуть дважды, а дубль «принято» в теме выглядит как второй человек. """ _purge() with _LOCK: entry = _PENDING.get(token) if entry is None: # Неизвестный или протухший токен. Не рассказываем, что именно: эндпоинт # публичный, и перебор не должен получать подсказок. return 404, _PAGE.format( t="Не найдено", h="Ссылка недействительна", p="Возможно, инцидент уже подтверждён или ссылка устарела.", ) if entry["acked_by"]: return 200, _PAGE.format( t="Уже подтверждено", h="Уже подтверждено", p=f"Инцидент «{html.escape(entry['title'])}» уже отмечен как принятый.", ) who = ONCALL or "дежурный" when = time.strftime("%H:%M", time.localtime()) with _LOCK: entry["acked_by"] = who reply = { "chat_id": CHAT_ID, "text": ( f"✅ Принято в работу · {html.escape(entry['title'])}\n" f"{html.escape(who)}, {when}" ), "parse_mode": "HTML", "reply_to_message_id": entry["message_id"], } if TOPIC_ID: reply["message_thread_id"] = TOPIC_ID _tg("sendMessage", reply) # Снимаем кнопку у исходного сообщения, чтобы её не жали повторно. _tg( "editMessageReplyMarkup", {"chat_id": CHAT_ID, "message_id": entry["message_id"], "reply_markup": json.dumps({})}, ) return 200, _PAGE.format( t="Принято", h="Принято в работу", p=f"Отметка отправлена в чат. Инцидент: «{html.escape(entry['title'])}».", ) class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib log.info("%s %s", self.address_string(), fmt % args) def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None: self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self) -> None: # noqa: N802 — имя из stdlib if self.path == "/healthz": self._reply(200, b"ok", "text/plain; charset=utf-8") return if not self.path.startswith("/ack/"): self._reply(404, b"not found", "text/plain; charset=utf-8") return code, page = do_ack(self.path[len("/ack/") :].split("?")[0]) self._reply(code, page.encode()) def do_POST(self) -> None: # noqa: N802 — имя из stdlib # Тело читается ДО любой развилки и ветки отказа. protocol_version = # HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами # держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит # оставить его в сокете — и следующий запрос по тому же соединению # начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный # зонд на /glitchtip, а следом законный алерт получил # 501 Unsupported method ('{"text":"probe"}POST'). То есть один # отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот # резервный канал и заводился. parsed = urllib.parse.urlsplit(self.path) length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"{}" if parsed.path == "/glitchtip": self._handle_glitchtip(parsed, raw) return if parsed.path != "/alertmanager": self._reply(404, b"not found", "text/plain; charset=utf-8") return try: payload = json.loads(raw.decode() or "{}") except Exception: # noqa: BLE001 log.warning("вебхук: тело не разобрано, %d байт", len(raw)) self._reply(400, b"bad json", "text/plain; charset=utf-8") return # Отвечаем Alertmanager сразу: его таймаут короткий, а отправка в # Telegram может занять до трёх попыток. Иначе он посчитает доставку # неудачной и начнёт слать повторы поверх уже отправленного. self._reply(200, b"accepted", "text/plain; charset=utf-8") threading.Thread(target=_send_alert, args=(payload,), daemon=True).start() def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None: """POST /glitchtip — резервный получатель GlitchTip-алертов (#3471). Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6 заголовков не шлёт вовсе). Несконфигурированный секрет → 503, а не тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет запрос — это резервный канал, теряться на кривом JSON ему нельзя. """ if not GLITCHTIP_SECRET: self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8") return header_secret = self.headers.get("X-GlitchTip-Secret", "") query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0] if not _verify_glitchtip_secret(header_secret or query_secret): log.warning("glitchtip webhook: invalid or missing secret") self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8") return try: payload = json.loads(raw.decode() or "{}") if not isinstance(payload, dict): payload = {"text": raw.decode(errors="replace")} except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле payload = {"text": raw.decode(errors="replace")} self._reply(200, b"accepted", "text/plain; charset=utf-8") threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start() def main() -> None: missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v] if missing: raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}") if not PUBLIC_URL: log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения") if not GLITCHTIP_SECRET: log.warning( "ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) " "отключён, POST /glitchtip будет отвечать 503" ) port = int(os.environ.get("ALERT_ACK_PORT", "8080")) log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—") ThreadingHTTPServer(("", port), Handler).serve_forever() if __name__ == "__main__": main()