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
+96 -8

No files matched your search

+28 -2
View File
@@ -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
),
},
)
+17 -4
View File
@@ -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
+51 -2
View File
@@ -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 消息、分片、长轮询与回调授权测试全部通过")