From 2d42d8ed5530db354a03b63b41fb102c1669efde Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sat, 31 Oct 2026 21:59:04 +0800 Subject: [PATCH] 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. --- reaction_board/notify/telegram.py | 30 +++++++++++- reaction_board/services/telegram_poller.py | 21 +++++++-- tests/test_telegram.py | 53 +++++++++++++++++++++- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/reaction_board/notify/telegram.py b/reaction_board/notify/telegram.py index 301c566..3ff41d7 100644 --- a/reaction_board/notify/telegram.py +++ b/reaction_board/notify/telegram.py @@ -5,6 +5,7 @@ from __future__ import annotations import html import json +import urllib.error import urllib.parse import urllib.request from typing import Any, Mapping, Protocol, Sequence @@ -27,8 +28,18 @@ class UrllibTelegramTransport: data=urllib.parse.urlencode(data).encode("utf-8"), method="POST", ) - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8", "replace")) + 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: @@ -142,3 +153,18 @@ class TelegramClient: "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 + ), + }, + ) diff --git a/reaction_board/services/telegram_poller.py b/reaction_board/services/telegram_poller.py index 566a093..917338e 100644 --- a/reaction_board/services/telegram_poller.py +++ b/reaction_board/services/telegram_poller.py @@ -67,13 +67,26 @@ class TelegramPoller: action=action, aid=aid, ok=result.ok, state=result.state, position=result.position, ) - self._answer(callback_id, text) + if not self._answer(callback_id, text): + # 回调 ID 过期时 answerCallbackQuery 会 400;改用普通消息告知结果。 + try: + self.bot.send_text(text) + except Exception as exc: # noqa: BLE001 + self._log("warning", "telegram.fallback_failed", error=str(exc)) + message_id = message.get("message_id") + if isinstance(message_id, int) and not isinstance(message_id, bool): + try: + self.bot.edit_reply_markup(message_id) + except Exception as exc: # noqa: BLE001 + self._log("warning", "telegram.edit_failed", error=str(exc)) - def _answer(self, callback_id: str, text: str) -> None: + def _answer(self, callback_id: str, text: str) -> bool: try: self.bot.answer_callback(callback_id, text) - except Exception as exc: # noqa: BLE001 - self._log("error", "telegram.answer_failed", error=str(exc)) + return True + except Exception as exc: # noqa: BLE001 过期回调不应升级为错误 + self._log("warning", "telegram.answer_failed", error=str(exc)) + return False def run_forever(self, stop_event=None) -> None: offset: int | None = None diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 4e6ef8b..4fc5b8b 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -12,6 +12,7 @@ sys.stdout.reconfigure(encoding="utf-8") from reaction_board.notify.telegram import ( # noqa: E402 TelegramClient, + TelegramError, format_duration, ) from reaction_board.services.favorites import ActionResult # noqa: E402 @@ -94,20 +95,54 @@ def test_get_updates_and_answer(): assert transport.calls[0]["url"].endswith("/answerCallbackQuery") assert transport.calls[0]["data"]["callback_query_id"] == "cb1" + transport = FakeTransport(result=True) + bot = TelegramClient("token", 42, transport=transport) + bot.edit_reply_markup(77) + assert transport.calls[0]["url"].endswith("/editMessageReplyMarkup") + assert transport.calls[0]["data"]["message_id"] == 77 + assert json.loads(transport.calls[0]["data"]["reply_markup"]) == { + "inline_keyboard": [] + } + + +def test_telegram_error_description_preserved(): + class ErrorTransport: + def post(self, url, data, timeout=30): + return {"ok": False, "description": "query is too old"} + + bot = TelegramClient("token", 42, transport=ErrorTransport()) + try: + bot.answer_callback("cb1", "x") + except TelegramError as exc: + assert "too old" in str(exc) + else: + raise AssertionError("Telegram 业务错误应抛出描述") + class FakeBot: chat_id = 42 - def __init__(self, updates): + def __init__(self, updates, *, fail_answer=False): self.updates = updates + self.fail_answer = fail_answer self.answers = [] + self.sent = [] + self.edits = [] def get_updates(self, offset=None): return list(self.updates) def answer_callback(self, callback_id, text=""): + if self.fail_answer: + raise RuntimeError("HTTP Error 400: Bad Request") self.answers.append((callback_id, text)) + def send_text(self, text): + self.sent.append(text) + + def edit_reply_markup(self, message_id): + self.edits.append(message_id) + class FakeActions: def __init__(self): @@ -133,7 +168,7 @@ def update(update_id, data, *, chat_id=42, from_id=42): "id": f"cb{update_id}", "data": data, "from": {"id": from_id}, - "message": {"chat": {"id": chat_id}}, + "message": {"message_id": update_id * 10, "chat": {"id": chat_id}}, }, } @@ -154,6 +189,18 @@ def test_poller_dispatch_and_authorization(): assert bot.answers[0] == ("cb1", "已加入收藏夹(位置 #3)") assert bot.answers[3] == ("cb4", "无权限") assert bot.answers[4] == ("cb5", "未知操作") + assert bot.edits == [10, 20, 30] + + +def test_answer_failure_falls_back_to_text(): + bot = FakeBot([update(1, "fav:9")], fail_answer=True) + actions = FakeActions() + poller = TelegramPoller(bot, actions) + poller.run_once(None) + assert actions.calls == [("fav", 9)] + assert bot.answers == [] + assert bot.sent == ["已加入收藏夹(位置 #3)"] + assert bot.edits == [10] def main(): @@ -161,7 +208,9 @@ def main(): test_send_candidates_and_chunking() test_send_candidates_dedupes_by_aid() test_get_updates_and_answer() + test_telegram_error_description_preserved() test_poller_dispatch_and_authorization() + test_answer_failure_falls_back_to_text() print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")