Files
re-xxm/reaction_board/notify/telegram.py
T
kairee 2d42d8ed55 fix(telegram): handle expired callback queries gracefully
Parse Telegram HTTP error bodies so the real description is logged, fall back to a normal message when answerCallbackQuery rejects an expired callback ID, and remove inline buttons after a successful action.
2026-10-31 21:59:04 +08:00

171 lines
6.1 KiB
Python

# -*- coding: utf-8 -*-
"""Telegram Bot 通知与回调。"""
from __future__ import annotations
import html
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Mapping, Protocol, Sequence
MAX_CANDIDATES_PER_MESSAGE = 3
class TelegramError(RuntimeError):
pass
class TelegramTransport(Protocol):
def post(self, url: str, data: Mapping[str, Any], timeout: float = 30) -> Any: ...
class UrllibTelegramTransport:
def post(self, url: str, data: Mapping[str, Any], timeout: float = 30) -> Any:
request = urllib.request.Request(
url,
data=urllib.parse.urlencode(data).encode("utf-8"),
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as exc:
# Telegram 对业务错误也返回 4xx + JSON 描述;保留描述便于定位。
body = exc.read().decode("utf-8", "replace")
try:
return json.loads(body)
except json.JSONDecodeError as parse_error:
raise TelegramError(
f"Telegram HTTP {exc.code}: {body[:200]}"
) from parse_error
def format_duration(seconds: int) -> str:
seconds = max(0, int(seconds or 0))
hours, remainder = divmod(seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def format_candidate(candidate: Mapping[str, Any]) -> str:
title = html.escape(str(candidate.get("title") or "(无标题)"))
up_name = html.escape(str(candidate.get("up_name") or "未知UP"))
duration = format_duration(int(candidate.get("duration") or 0))
play = int(candidate.get("play") or 0)
like = int(candidate.get("like") or 0)
return (
f"🎬 <b>{title}</b>\n"
f"UP: {up_name}\n"
f"时长 {duration} · 播放 {play} · 点赞 {like}(索引值)"
)
class TelegramClient:
def __init__(
self,
token: str,
chat_id: int,
*,
transport: TelegramTransport | None = None,
timeout: float = 30,
poll_timeout: int = 25,
logger=None,
) -> None:
if not token:
raise ValueError("telegram.bot_token 不能为空")
if not chat_id:
raise ValueError("telegram.chat_id 不能为空")
self.token = token
self.chat_id = int(chat_id)
self.transport = transport or UrllibTelegramTransport()
self.timeout = timeout
self.poll_timeout = poll_timeout
self.logger = logger
def _call(self, method: str, params: Mapping[str, Any] | None = None) -> Any:
url = f"https://api.telegram.org/bot{self.token}/{method}"
payload = self.transport.post(url, params or {}, timeout=self.timeout)
if not isinstance(payload, Mapping):
raise TelegramError("Telegram 响应不是对象")
if not payload.get("ok"):
raise TelegramError(str(payload.get("description") or "Telegram API 错误"))
return payload.get("result")
def send_text(
self, text: str, *, reply_markup: Mapping[str, Any] | None = None
) -> Any:
params: dict[str, Any] = {
"chat_id": self.chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
if reply_markup is not None:
params["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
return self._call("sendMessage", params)
def send_candidates(self, candidates: Sequence[Mapping[str, Any]]) -> bool:
# 通知器边界再做一次 aid 去重,避免调用方重复入队导致消息主体和按钮重复。
items: list[Mapping[str, Any]] = []
seen: set[int] = set()
for candidate in candidates:
aid = candidate.get("aid")
if aid in seen:
continue
seen.add(aid)
items.append(candidate)
if not items:
return True
for index in range(0, len(items), MAX_CANDIDATES_PER_MESSAGE):
chunk = items[index:index + MAX_CANDIDATES_PER_MESSAGE]
text = "\n\n".join(format_candidate(candidate) for candidate in chunk)
keyboard = {
"inline_keyboard": [
[
{"text": "⭐ 收藏", "callback_data": f"fav:{candidate['aid']}"},
{"text": "🙈 忽略", "callback_data": f"ign:{candidate['aid']}"},
{"text": "🚫 拉黑", "callback_data": f"blk:{candidate['aid']}"},
]
for candidate in chunk
]
}
self.send_text(text, reply_markup=keyboard)
return True
def get_updates(
self, offset: int | None = None, *, timeout: int | None = None
) -> list[dict[str, Any]]:
params: dict[str, Any] = {
"timeout": self.poll_timeout if timeout is None else int(timeout),
"allowed_updates": json.dumps(["callback_query"]),
}
if offset is not None:
params["offset"] = int(offset)
result = self._call("getUpdates", params)
return result if isinstance(result, list) else []
def answer_callback(self, callback_id: str, text: str = "") -> Any:
return self._call(
"answerCallbackQuery",
{"callback_query_id": callback_id, "text": text[:200]},
)
def edit_reply_markup(
self, message_id: int, *, reply_markup: Mapping[str, Any] | None = None
) -> Any:
"""移除或替换原消息的按钮;空 keyboard 表示移除。"""
return self._call(
"editMessageReplyMarkup",
{
"chat_id": self.chat_id,
"message_id": int(message_id),
"reply_markup": json.dumps(
reply_markup or {"inline_keyboard": []}, ensure_ascii=False
),
},
)