162 lines
6.4 KiB
Python
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()
|