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.
This commit is contained in:
kairee committed 2026-10-31 21:59:04 +08:00
1 parent e2e4dc3336
commit 2d42d8ed55
3 files changed
+94 -6

No files matched your search

+26
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import html import html
import json import json
import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Any, Mapping, Protocol, Sequence from typing import Any, Mapping, Protocol, Sequence
@@ -27,8 +28,18 @@ class UrllibTelegramTransport:
data=urllib.parse.urlencode(data).encode("utf-8"), data=urllib.parse.urlencode(data).encode("utf-8"),
method="POST", method="POST",
) )
try:
with urllib.request.urlopen(request, timeout=timeout) as response: with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8", "replace")) 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: def format_duration(seconds: int) -> str:
@@ -142,3 +153,18 @@ class TelegramClient:
"answerCallbackQuery", "answerCallbackQuery",
{"callback_query_id": callback_id, "text": text[:200]}, {"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
),
},
)
+17 -4
View File
@@ -67,13 +67,26 @@ class TelegramPoller:
action=action, aid=aid, ok=result.ok, state=result.state, action=action, aid=aid, ok=result.ok, state=result.state,
position=result.position, 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: try:
self.bot.answer_callback(callback_id, text) self.bot.answer_callback(callback_id, text)
except Exception as exc: # noqa: BLE001 return True
self._log("error", "telegram.answer_failed", error=str(exc)) 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: def run_forever(self, stop_event=None) -> None:
offset: int | None = None offset: int | None = None
+51 -2
View File
@@ -12,6 +12,7 @@ sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.notify.telegram import ( # noqa: E402 from reaction_board.notify.telegram import ( # noqa: E402
TelegramClient, TelegramClient,
TelegramError,
format_duration, format_duration,
) )
from reaction_board.services.favorites import ActionResult # noqa: E402 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]["url"].endswith("/answerCallbackQuery")
assert transport.calls[0]["data"]["callback_query_id"] == "cb1" 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: class FakeBot:
chat_id = 42 chat_id = 42
def __init__(self, updates): def __init__(self, updates, *, fail_answer=False):
self.updates = updates self.updates = updates
self.fail_answer = fail_answer
self.answers = [] self.answers = []
self.sent = []
self.edits = []
def get_updates(self, offset=None): def get_updates(self, offset=None):
return list(self.updates) return list(self.updates)
def answer_callback(self, callback_id, text=""): def answer_callback(self, callback_id, text=""):
if self.fail_answer:
raise RuntimeError("HTTP Error 400: Bad Request")
self.answers.append((callback_id, text)) 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: class FakeActions:
def __init__(self): def __init__(self):
@@ -133,7 +168,7 @@ def update(update_id, data, *, chat_id=42, from_id=42):
"id": f"cb{update_id}", "id": f"cb{update_id}",
"data": data, "data": data,
"from": {"id": from_id}, "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[0] == ("cb1", "已加入收藏夹(位置 #3)")
assert bot.answers[3] == ("cb4", "无权限") assert bot.answers[3] == ("cb4", "无权限")
assert bot.answers[4] == ("cb5", "未知操作") 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(): def main():
@@ -161,7 +208,9 @@ def main():
test_send_candidates_and_chunking() test_send_candidates_and_chunking()
test_send_candidates_dedupes_by_aid() test_send_candidates_dedupes_by_aid()
test_get_updates_and_answer() test_get_updates_and_answer()
test_telegram_error_description_preserved()
test_poller_dispatch_and_authorization() test_poller_dispatch_and_authorization()
test_answer_failure_falls_back_to_text()
print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过") print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")