"""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/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"]*>", 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()