Initial commit
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user