Files
US2KR/us2kr/deliver/telegram.py
T
2026-08-31 14:33:53 +09:00

162 lines
6.4 KiB
Python

"""Telegram Bot API deliverer (raw HTTPS via httpx; no bot framework).
Security: the bot token is part of the request URL, so this module never logs URLs or raw
exceptions unmasked and silences httpx's own INFO request log (which would print the URL).
"""
from __future__ import annotations
import html
import logging
import re
import time
from typing import Any
import httpx
from us2kr.deliver.base import DeliveryError
from us2kr.models import BriefingResult
log = logging.getLogger(__name__)
# httpx logs 'HTTP Request: POST https://api.telegram.org/bot<TOKEN>/sendMessage ...' at INFO.
logging.getLogger("httpx").setLevel(logging.WARNING)
API_BASE = "https://api.telegram.org"
MAX_ATTEMPTS = 3
BACKOFF_BASE_SECONDS = 1.0
_PARSE_ERROR_MARK = "can't parse entities"
_TAG_RE = re.compile(r"</?(?:b|i|a|pre|code|u|s|strong|em)\b[^>]*>", re.IGNORECASE)
def _to_plain(text: str) -> str:
"""Strip the Telegram-HTML subset and unescape entities for a parse_mode-less fallback."""
return html.unescape(_TAG_RE.sub("", text))
class TelegramDeliverer:
"""Send the briefing as one or more HTML messages to a chat."""
name = "telegram"
def __init__(self, bot_token: str, chat_id: str, client: httpx.Client | None = None) -> None:
if not bot_token or not chat_id:
raise ValueError("TelegramDeliverer requires both bot_token and chat_id")
self._token = bot_token
self.chat_id = chat_id
self._client = client or httpx.Client(timeout=httpx.Timeout(30.0))
self._url = f"{API_BASE}/bot{bot_token}/sendMessage"
# ------------------------------------------------------------------ helpers
def _mask(self, text: str) -> str:
return text.replace(self._token, "***") if self._token else text
@staticmethod
def _retry_after(resp: httpx.Response, body: dict[str, Any] | None) -> float | None:
if body:
val = (body.get("parameters") or {}).get("retry_after")
if isinstance(val, (int, float)):
return float(val)
hdr = resp.headers.get("Retry-After")
if hdr:
try:
return float(hdr)
except ValueError:
return None
return None
@staticmethod
def _json(resp: httpx.Response) -> dict[str, Any] | None:
try:
body = resp.json()
except ValueError:
return None
return body if isinstance(body, dict) else None
def _send(self, text: str, parse_mode: str | None = "HTML") -> str:
"""POST one sendMessage with retries. Return the message_id as a string."""
payload: dict[str, Any] = {
"chat_id": self.chat_id,
"text": text,
"disable_web_page_preview": True,
}
if parse_mode:
payload["parse_mode"] = parse_mode
last_error = "unknown"
for attempt in range(1, MAX_ATTEMPTS + 1):
backoff = BACKOFF_BASE_SECONDS * (2 ** (attempt - 1))
try:
resp = self._client.post(self._url, json=payload)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {self._mask(str(exc))}"
log.warning("telegram request error (attempt %d/%d): %s", attempt, MAX_ATTEMPTS, last_error)
if attempt < MAX_ATTEMPTS:
time.sleep(backoff)
continue
body = self._json(resp)
description = str((body or {}).get("description", ""))
status = resp.status_code
if status == 200 and body and body.get("ok"):
message_id = (body.get("result") or {}).get("message_id")
log.info("telegram message sent (parse_mode=%s, id=%s)", parse_mode, message_id)
return str(message_id)
if status == 429:
wait = self._retry_after(resp, body) or backoff
last_error = f"429 rate limited: {description}"
log.warning("telegram 429 (attempt %d/%d), waiting %.1fs", attempt, MAX_ATTEMPTS, wait)
if attempt < MAX_ATTEMPTS:
time.sleep(max(wait, backoff))
continue
if status >= 500:
last_error = f"{status} server error: {description}"
log.warning("telegram %d (attempt %d/%d): %s", status, attempt, MAX_ATTEMPTS, description)
if attempt < MAX_ATTEMPTS:
time.sleep(backoff)
continue
if status == 400 and parse_mode and _PARSE_ERROR_MARK in description.lower():
log.warning("telegram rejected HTML (%s); resending chunk as plain text", description)
return self._send(_to_plain(text), parse_mode=None)
# Any other 4xx (or 200 with ok=false) is not retryable.
raise DeliveryError(
f"telegram sendMessage failed: HTTP {status}: {description or 'no description'}"
)
raise DeliveryError(f"telegram sendMessage failed after {MAX_ATTEMPTS} attempts: {last_error}")
# ---------------------------------------------------------------- interface
def deliver(self, result: BriefingResult) -> str:
"""Send the rendered briefing in Telegram-safe HTML chunks; return first message_id."""
# Lazy import: render is an independent module and tests may monkeypatch it.
from us2kr.render import render_telegram_chunks
chunks = render_telegram_chunks(result.markdown)
if not chunks:
raise DeliveryError("telegram: nothing to send (render produced no chunks)")
first_id: str | None = None
for idx, chunk in enumerate(chunks, start=1):
log.debug("telegram sending chunk %d/%d (%d chars)", idx, len(chunks), len(chunk))
message_id = self._send(chunk, parse_mode="HTML")
if first_id is None:
first_id = message_id
log.info("telegram delivery complete: %d chunk(s), first id=%s", len(chunks), first_id)
return first_id or ""
def notify_error(self, subject: str, detail: str) -> None:
"""Send a short plain-text operator alert. Never raises."""
text = f"⚠️ US2KR 오류: {subject}\n{detail[:1500]}"
try:
self._send(text, parse_mode=None)
except Exception as exc: # noqa: BLE001 - alerts must never propagate
log.error("telegram notify_error failed: %s: %s", type(exc).__name__, self._mask(str(exc)))
def close(self) -> None:
self._client.close()