#!/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) """ 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 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) _PAGE = ( "" "
{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 if self.path != "/alertmanager": self._reply(404, b"not found", "text/plain; charset=utf-8") return length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"{}" 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 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 пуст — сообщения уйдут БЕЗ кнопки подтверждения") 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()