Files
checkbot/app/chat/telegram.py
T
patrickandClaude Sonnet 5.5 347f55cf7c
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run
feat: Phase 6c baseline, ack, host alarms, outage notice, send limit, privacy mode, UI link
Includes atomic migrations, per-rule reminder semantics, first-run helper script
and the note on the undecided UI chat idea.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 03:06:23 +01:00

138 lines
5.7 KiB
Python

import asyncio
import httpx
from ..constants import DEFAULT_TELEGRAM_API_BASE
from .base import Button, Buttons, ChatError, MessageRef, json_body, mask_secret
BACKEND_TELEGRAM = "telegram"
PARSE_MODE = "HTML"
DEFAULT_TIMEOUT = 15.0
MAX_RETRY_AFTER = 30.0
DEFAULT_RETRY_AFTER = 1.0
STATUS_TOO_MANY = 429
NOT_MODIFIED = "message is not modified"
ALLOWED_UPDATES = ["message", "callback_query"]
ANSWER_MAX = 200
ERROR_MAX = 300
URL_BUTTON_ERRORS = ("button_url_invalid", "keyboard button url")
def _button(b: Button) -> dict:
return {"text": b.label, "url": b.url} if b.url else {"text": b.label, "callback_data": b.data}
def _markup(buttons: Buttons) -> dict:
return {"inline_keyboard": [[_button(b) for b in row] for row in buttons]}
def _without_url_buttons(buttons: Buttons) -> Buttons:
rows = (tuple(b for b in row if not b.url) for row in buttons)
return tuple(row for row in rows if row)
class TelegramError(ChatError):
pass
class TelegramAdapter:
name = BACKEND_TELEGRAM
def __init__(self, token: str, default_target: str, api_base: str = DEFAULT_TELEGRAM_API_BASE,
timeout: float = DEFAULT_TIMEOUT, transport: httpx.AsyncBaseTransport | None = None,
sleep=asyncio.sleep):
self.default_target = default_target
self._token = token
self._api_base = api_base.rstrip("/")
self._sleep = sleep
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
def _mask(self, text: str) -> str:
return mask_secret(text, self._token)
async def _call(self, method: str, payload: dict | None = None) -> dict:
url = f"{self._api_base}/bot{self._token}/{method}"
for attempt in range(2):
try:
response = await self._client.post(url, json=payload or {})
except (httpx.HTTPError, httpx.InvalidURL) as exc:
raise TelegramError(self._mask(f"{type(exc).__name__}: {exc}")) from None
body = json_body(response)
if response.status_code == STATUS_TOO_MANY and attempt == 0:
retry_after = body.get("parameters", {}).get("retry_after", DEFAULT_RETRY_AFTER)
await self._sleep(min(float(retry_after), MAX_RETRY_AFTER))
continue
if response.status_code < 400 and body.get("ok"):
return body.get("result") or {}
description = str(body.get("description", ""))
raise TelegramError(self._mask(f"HTTP {response.status_code}: {description}")[:ERROR_MAX])
raise TelegramError("Telegram nicht erreichbar (Rate-Limit)")
async def _call_buttons(self, method: str, payload: dict, buttons: Buttons) -> dict:
try:
return await self._call(method, payload)
except TelegramError as exc:
# Telegram lehnt manche URLs (z. B. localhost) ab; ohne Link-Button soll der Alarm trotzdem ankommen.
if not any(m in str(exc).lower() for m in URL_BUTTON_ERRORS) or not any(b.url for row in buttons for b in row):
raise
remaining = _without_url_buttons(buttons)
payload = {k: v for k, v in payload.items() if k != "reply_markup"}
if remaining:
payload["reply_markup"] = _markup(remaining)
return await self._call(method, payload)
async def send(self, target: str, text: str, buttons: Buttons = (), reply_to: str | None = None,
html: bool = True, silent: bool = False) -> MessageRef:
payload: dict = {"chat_id": target, "text": text, "disable_web_page_preview": True}
if silent:
payload["disable_notification"] = True
if html:
payload["parse_mode"] = PARSE_MODE
if buttons:
payload["reply_markup"] = _markup(buttons)
if reply_to:
payload["reply_parameters"] = {"message_id": int(reply_to), "allow_sending_without_reply": True}
result = await self._call_buttons("sendMessage", payload, buttons)
return MessageRef(self.name, str(target), str(result["message_id"]))
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = (), silent: bool = False) -> None:
payload: dict = {"chat_id": ref.target, "message_id": int(ref.message_id), "text": text,
"parse_mode": PARSE_MODE, "disable_web_page_preview": True}
if buttons:
payload["reply_markup"] = _markup(buttons)
try:
await self._call_buttons("editMessageText", payload, buttons)
except TelegramError as exc:
if NOT_MODIFIED not in str(exc).lower():
raise
async def get_updates(self, offset: int | None, timeout: int) -> list[dict]:
payload: dict = {"timeout": timeout, "allowed_updates": ALLOWED_UPDATES}
if offset is not None:
payload["offset"] = offset
result = await self._call("getUpdates", payload)
return result if isinstance(result, list) else []
async def answer_callback(self, callback_id: str, text: str = "") -> None:
payload = {"callback_query_id": callback_id}
if text:
payload["text"] = text[:ANSWER_MAX]
await self._call("answerCallbackQuery", payload)
async def clear_buttons(self, target: str, message_id: str) -> None:
await self._call("editMessageReplyMarkup", {
"chat_id": target, "message_id": int(message_id), "reply_markup": {"inline_keyboard": []}})
async def username(self) -> str | None:
result = await self._call("getMe")
return result.get("username")
async def check(self) -> str:
result = await self._call("getMe")
return f"Bot @{result.get('username', '?')}"
async def aclose(self) -> None:
await self._client.aclose()