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:
1 parent
e2e4dc3336
commit
2d42d8ed55
3 files changed
+94
-6
No files matched your search
@@ -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
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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
@@ -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 消息、分片、长轮询与回调授权测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user