fix(telegram): dedupe webhook+polling updates via shared DB guard
All checks were successful
CD Pipeline / deploy (push) Successful in 8m50s
All checks were successful
CD Pipeline / deploy (push) Successful in 8m50s
Webhook (Flask) and polling (momo-telegram-bot) consumed the same Telegram update_id, causing /menu callbacks to fire twice. Add a shared dedup module backed by telegram_update_dedup table (300s TTL, 60s cleanup) with in-memory fallback, wired into both paths. Polling launcher now skips startup when webhook is configured to prevent dual-consumption at the source. 38 tests across webhook, menu keyboards, telegram_api, dedup guard, and trend bot service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,9 +55,97 @@ def test_category_menu_and_submenu_registry_are_stable():
|
||||
}
|
||||
|
||||
|
||||
def test_competitor_menu_keeps_date_input_action():
|
||||
from services.openclaw_bot import menu_keyboards
|
||||
|
||||
menu_keyboards.configure_menu_keyboards(latest_date_provider=lambda: "2026/04/30")
|
||||
|
||||
rows = menu_keyboards._submenu_competitor()
|
||||
|
||||
assert any("指定日期" in button["text"] and button["callback_data"] == "await:date_competitor"
|
||||
for row in rows for button in row)
|
||||
|
||||
|
||||
def test_competitor_ppt_menu_layout_stays_row_based():
|
||||
from services.openclaw_bot import menu_keyboards
|
||||
|
||||
rows = menu_keyboards._submenu_competitor_ppt()
|
||||
|
||||
assert rows[0][0]["text"] == "📆 半年比較"
|
||||
assert rows[0][1]["text"] == "🗓 年比較"
|
||||
assert rows[-1] == menu_keyboards._BACK
|
||||
|
||||
|
||||
def test_chunk_rows_respects_row_size():
|
||||
from services.openclaw_bot import menu_keyboards
|
||||
|
||||
rows = menu_keyboards._chunk_rows(
|
||||
[
|
||||
('A', 'a'),
|
||||
('B', 'b'),
|
||||
('C', 'c'),
|
||||
],
|
||||
row_size=2,
|
||||
)
|
||||
assert rows == [
|
||||
[{'text': 'A', 'callback_data': 'a'}, {'text': 'B', 'callback_data': 'b'}],
|
||||
[{'text': 'C', 'callback_data': 'c'}],
|
||||
]
|
||||
|
||||
|
||||
def test_openclaw_routes_import_menu_keyboard_helpers():
|
||||
route_source = Path("routes/openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "from services.openclaw_bot.menu_keyboards import" in route_source
|
||||
assert "configure_menu_keyboards(latest_date_provider=latest_date" in route_source
|
||||
assert "def main_menu_keyboard():" not in route_source
|
||||
|
||||
|
||||
def test_quick_menu_keyboard_has_two_column_layout():
|
||||
from services.openclaw_bot import menu_keyboards
|
||||
|
||||
menu_keyboards.configure_menu_keyboards()
|
||||
rows = menu_keyboards.quick_menu_keyboard()
|
||||
|
||||
assert len(rows) == 3
|
||||
assert all(1 <= len(row) <= 2 for row in rows)
|
||||
assert rows[0][0]['callback_data'].startswith('menu:')
|
||||
|
||||
|
||||
def test_polling_telegram_bot_bridges_openclaw_menu_callbacks():
|
||||
service_source = Path("services/telegram_bot_service.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'CommandHandler("menu", self.cmd_menu)' in service_source
|
||||
assert "data.startswith(('menu:', 'cmd:', 'await:'))" in service_source
|
||||
assert "openclaw_waiting_for" in service_source
|
||||
|
||||
|
||||
def test_market_info_handlers_accept_text_mcp_contract():
|
||||
route_source = Path("routes/openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "def _send_mcp_text_result" in route_source
|
||||
assert "data = get_upcoming_events()" in route_source
|
||||
assert "get_upcoming_events(60)" not in route_source
|
||||
|
||||
|
||||
def test_mcp_collector_has_stable_fallbacks():
|
||||
source = Path("services/mcp_collector_service.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "def _fallback_topic_content" in source
|
||||
assert "def _looks_unreliable" in source
|
||||
assert '["google_search"]' in source
|
||||
assert "return self._fallback_topic_content" in source
|
||||
|
||||
|
||||
def test_polling_menu_imports_openclaw_routes_for_runtime_configuration():
|
||||
service_source = Path("services/telegram_bot_service.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "from routes import openclaw_bot_routes as openclaw" in service_source
|
||||
assert "openclaw.main_menu_keyboard()" in service_source
|
||||
assert "openclaw._SUBMENUS.get(key)" in service_source
|
||||
|
||||
|
||||
def test_help_keyboard_uses_reusable_quick_menu():
|
||||
route_source = Path("routes/openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "help_kb = quick_menu_keyboard()" in route_source
|
||||
|
||||
548
tests/test_openclaw_bot_routes_webhook.py
Normal file
548
tests/test_openclaw_bot_routes_webhook.py
Normal file
@@ -0,0 +1,548 @@
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def _build_request_app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
def test_webhook_menu_command_handles_bot_suffix(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "BOT_USERNAME", "@KnownBot")
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10001,
|
||||
"message": {
|
||||
"message_id": 55,
|
||||
"chat": {"id": -200, "type": "supergroup"},
|
||||
"from": {"id": 777},
|
||||
"text": "/menu@OtherBot",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == [("menu", "", -200, 55)]
|
||||
|
||||
|
||||
def test_private_menu_command_is_allowed_when_no_whitelist_and_fallback_enabled(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
monkeypatch.setattr(bot, "BOT_USERNAME", "@KnownBot")
|
||||
monkeypatch.setattr(bot, "_ALLOW_PRIVATE_WITHOUT_WHITELIST", True)
|
||||
monkeypatch.setattr(bot, "ALLOWED_USERS", set())
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10020,
|
||||
"message": {
|
||||
"message_id": 55,
|
||||
"chat": {"id": 777, "type": "private"},
|
||||
"from": {"id": 777777},
|
||||
"text": "/menu",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == [("menu", "", 777, 55)]
|
||||
|
||||
|
||||
def test_is_authorized_private_mode_switch(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
monkeypatch.setattr(bot, "ALLOWED_USERS", set())
|
||||
monkeypatch.setattr(bot, "_ALLOW_PRIVATE_WITHOUT_WHITELIST", True)
|
||||
assert bot._is_authorized("private", 777, 42) is True
|
||||
|
||||
monkeypatch.setattr(bot, "_ALLOW_PRIVATE_WITHOUT_WHITELIST", False)
|
||||
assert bot._is_authorized("private", 777, 42) is False
|
||||
|
||||
|
||||
def test_webhook_menu_callback_edits_existing_message(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
edited = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
edited.append((chat_id, message_id, text, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10002,
|
||||
"callback_query": {
|
||||
"id": "cb1",
|
||||
"from": {"id": 777},
|
||||
"message": {
|
||||
"message_id": 66,
|
||||
"chat": {"id": -200, "type": "supergroup"},
|
||||
},
|
||||
"data": "menu:main",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert len(edited) == 1
|
||||
chat_id, message_id, text, keyboard, parse_mode = edited[0]
|
||||
assert chat_id == -200
|
||||
assert message_id == 66
|
||||
assert text == "👋 *OpenClaw* — 請選擇功能類別"
|
||||
assert isinstance(keyboard, list)
|
||||
assert parse_mode == "Markdown"
|
||||
|
||||
|
||||
def test_webhook_legacy_menu_callback_normalizes_prefix(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
edited = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
edited.append((chat_id, message_id, text, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10005,
|
||||
"callback_query": {
|
||||
"id": "cb-legacy",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 123, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "menu_main",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert len(edited) == 1
|
||||
assert edited[0][0] == -200
|
||||
assert edited[0][1] == 123
|
||||
assert edited[0][2] == "👋 *OpenClaw* — 請選擇功能類別"
|
||||
|
||||
|
||||
def test_webhook_await_callback_edits_existing_message(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
edited = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
edited.append((chat_id, message_id, text, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10003,
|
||||
"callback_query": {
|
||||
"id": "cb2",
|
||||
"from": {"id": 777},
|
||||
"message": {
|
||||
"message_id": 77,
|
||||
"chat": {"id": -200, "type": "supergroup"},
|
||||
},
|
||||
"data": "await:date_range_sales",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert len(edited) == 1
|
||||
_, _, text, keyboard, _ = edited[0]
|
||||
assert "輸入 `/取消` 可退出_" in text
|
||||
assert keyboard == [[{"text": "✖ 取消", "callback_data": "menu:main"}]]
|
||||
|
||||
|
||||
def test_webhook_cmd_callback_updates_with_message_edit(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
edited = []
|
||||
sent = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
edited.append((chat_id, message_id, text, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
bot.send_message(chat_id, f"{cmd}:{arg}", reply_to=reply_to)
|
||||
|
||||
def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown"):
|
||||
sent.append((chat_id, text, reply_to, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "send_message", fake_send_message)
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10004,
|
||||
"callback_query": {
|
||||
"id": "cb3",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 88, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert len(edited) == 1
|
||||
assert edited[0][0] == -200
|
||||
assert edited[0][1] == 88
|
||||
assert edited[0][2] == "sales:2026/04/30"
|
||||
assert edited[0][4] == "Markdown"
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_webhook_duplicate_update_id_is_skipped(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
answered = []
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": answered.append(_cq_id))
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
monkeypatch.setattr(bot, "send_message", lambda *_args, **_kwargs: {"ok": True})
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 20001,
|
||||
"callback_query": {
|
||||
"id": "dup-cb",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 99, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "menu:main",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == []
|
||||
assert answered == ["dup-cb", "dup-cb"]
|
||||
|
||||
|
||||
def test_webhook_cmd_callback_ignores_not_modified(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
edited = []
|
||||
sent = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
edited.append((chat_id, message_id, text, keyboard, parse_mode))
|
||||
return {"ok": False, "description": "Bad Request: message is not modified"}
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
bot.send_message(chat_id, f"{cmd}:{arg}", reply_to=reply_to)
|
||||
|
||||
def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown", **_kwargs):
|
||||
sent.append((chat_id, text, reply_to, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "send_message", fake_send_message)
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 20002,
|
||||
"callback_query": {
|
||||
"id": "cb4",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 101, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert edited
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_webhook_menu_callback_does_not_duplicate_on_message_not_found(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
sent = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": 404,
|
||||
"description": "Bad Request: message to edit not found",
|
||||
}
|
||||
|
||||
def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown", **_kwargs):
|
||||
sent.append((chat_id, text, reply_to, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "send_message", fake_send_message)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10006,
|
||||
"callback_query": {
|
||||
"id": "cb5",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 222, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "menu:main",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_webhook_cmd_callback_does_not_duplicate_on_message_not_found(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
sent = []
|
||||
|
||||
def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": 404,
|
||||
"description": "Bad Request: MESSAGE_ID_INVALID",
|
||||
}
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
bot.send_message(chat_id, f"{cmd}:{arg}", reply_to=reply_to)
|
||||
|
||||
def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown", **_kwargs):
|
||||
sent.append((chat_id, text, reply_to, keyboard, parse_mode))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text)
|
||||
monkeypatch.setattr(bot, "send_message", fake_send_message)
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"update_id": 10007,
|
||||
"callback_query": {
|
||||
"id": "cb6",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 333, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/bot/telegram/webhook", method="POST", json=payload
|
||||
):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_webhook_callback_dedup_key_without_update_id(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
|
||||
# 當 callback 沒有 update_id 時,第二次同樣 payload 要直接被 dedupe,
|
||||
# 但第一次仍應可正常執行一次。
|
||||
from services import telegram_update_guard as guard
|
||||
guard._seen_update_ids.clear()
|
||||
guard._seen_update_id_set.clear()
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload = {
|
||||
"callback_query": {
|
||||
"id": "cb-no-id",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 123, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload):
|
||||
bot.telegram_webhook()
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == [('sales', '2026/04/30', -200, 123)]
|
||||
|
||||
|
||||
def test_webhook_callback_dedup_key_varies_by_message_id(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
|
||||
from services import telegram_update_guard as guard
|
||||
guard._seen_update_ids.clear()
|
||||
guard._seen_update_id_set.clear()
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload_1 = {
|
||||
"callback_query": {
|
||||
"id": "cb-no-id",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 201, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
payload_2 = {
|
||||
"callback_query": {
|
||||
"id": "cb-no-id",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 202, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload_1):
|
||||
bot.telegram_webhook()
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload_2):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == [
|
||||
('sales', '2026/04/30', -200, 201),
|
||||
('sales', '2026/04/30', -200, 202),
|
||||
]
|
||||
|
||||
|
||||
def test_webhook_callback_dedup_with_same_callback_query_id_different_update_id(monkeypatch):
|
||||
from routes import openclaw_bot_routes as bot
|
||||
|
||||
calls = []
|
||||
|
||||
from services import telegram_update_guard as guard
|
||||
guard._seen_update_ids.clear()
|
||||
guard._seen_update_id_set.clear()
|
||||
|
||||
def fake_handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
calls.append((cmd, arg, chat_id, reply_to))
|
||||
|
||||
monkeypatch.setattr(bot, "handle_cmd", fake_handle_cmd)
|
||||
monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True)
|
||||
monkeypatch.setattr(bot, "answer_callback", lambda _cq_id, text="": None)
|
||||
monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None)
|
||||
|
||||
app = _build_request_app()
|
||||
payload_1 = {
|
||||
"update_id": 30001,
|
||||
"callback_query": {
|
||||
"id": "cb-repeat",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 301, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
payload_2 = {
|
||||
"update_id": 30002,
|
||||
"callback_query": {
|
||||
"id": "cb-repeat",
|
||||
"from": {"id": 777},
|
||||
"message": {"message_id": 301, "chat": {"id": -200, "type": "supergroup"}},
|
||||
"data": "cmd:sales:2026/04/30",
|
||||
},
|
||||
}
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload_1):
|
||||
bot.telegram_webhook()
|
||||
|
||||
with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload_2):
|
||||
bot.telegram_webhook()
|
||||
|
||||
assert calls == [('sales', '2026/04/30', -200, 301)]
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
@@ -86,3 +87,15 @@ def test_openclaw_routes_keep_tg_helper_import_for_webhook_management():
|
||||
assert "_tg('setMyCommands'" in route_source
|
||||
assert "_tg('setWebhook'" in route_source
|
||||
assert " _tg,\n" in route_source
|
||||
|
||||
|
||||
def test_openclaw_telegram_api_falls_back_to_shared_bot_token(monkeypatch):
|
||||
monkeypatch.delenv("OPENCLAW_BOT_TOKEN", raising=False)
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "shared-token")
|
||||
|
||||
from services.openclaw_bot import telegram_api
|
||||
|
||||
reloaded = importlib.reload(telegram_api)
|
||||
|
||||
assert reloaded.BOT_TOKEN == "shared-token"
|
||||
assert reloaded.BOT_API_URL == "https://api.telegram.org/botshared-token"
|
||||
|
||||
26
tests/test_telegram_update_guard.py
Normal file
26
tests/test_telegram_update_guard.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from time import time
|
||||
|
||||
|
||||
def test_update_guard_detects_duplicate_key():
|
||||
from services import telegram_update_guard as guard
|
||||
|
||||
# 使用可變動 key,避免被歷史資料干擾
|
||||
unique = f"unit-test-{int(time() * 1000)}"
|
||||
|
||||
# 清掉本機快取,避免測試順序影響
|
||||
guard._seen_update_ids.clear()
|
||||
guard._seen_update_id_set.clear()
|
||||
|
||||
assert guard.is_duplicate_update(unique, namespace="pytest") is False
|
||||
assert guard.is_duplicate_update(unique, namespace="pytest") is True
|
||||
|
||||
|
||||
def test_update_guard_separates_namespace():
|
||||
from services import telegram_update_guard as guard
|
||||
|
||||
guard._seen_update_ids.clear()
|
||||
guard._seen_update_id_set.clear()
|
||||
|
||||
event_id = f"namespace-check-{int(time() * 1000)}"
|
||||
assert guard.is_duplicate_update(event_id, namespace="a") is False
|
||||
assert guard.is_duplicate_update(event_id, namespace="b") is False
|
||||
172
tests/test_trend_telegram_bot_service.py
Normal file
172
tests/test_trend_telegram_bot_service.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services import telegram_bot_service
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not telegram_bot_service.TELEGRAM_AVAILABLE,
|
||||
reason="python-telegram-bot 未安裝",
|
||||
)
|
||||
|
||||
|
||||
def _make_polling_update(query):
|
||||
"""建立最小的 polling callback 更新結構。"""
|
||||
return SimpleNamespace(callback_query=query)
|
||||
|
||||
|
||||
class _FakeMessage:
|
||||
def __init__(self, chat_id=-200, message_id=1):
|
||||
self.chat_id = chat_id
|
||||
self.message_id = message_id
|
||||
self.replies = []
|
||||
|
||||
async def reply_text(self, text, **kwargs):
|
||||
self.replies.append((text, kwargs))
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, query_id, data, message):
|
||||
self.id = query_id
|
||||
self.data = data
|
||||
self.message = message
|
||||
self.answers = 0
|
||||
|
||||
async def answer(self):
|
||||
self.answers += 1
|
||||
|
||||
async def edit_message_text(self, *args, **kwargs):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_polling_callback_dedup_without_update_id(monkeypatch):
|
||||
from services.telegram_bot_service import TrendTelegramBot
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_dedupe(_key, namespace="telegram_update"):
|
||||
if _key in seen:
|
||||
return True
|
||||
seen[_key] = True
|
||||
return False
|
||||
|
||||
bot = TrendTelegramBot(token="dummy")
|
||||
|
||||
called = []
|
||||
async def fake_openclaw_callback(*args):
|
||||
called.append(args)
|
||||
|
||||
monkeypatch.setattr(telegram_bot_service, "is_global_duplicate_update", fake_dedupe)
|
||||
bot._handle_openclaw_callback = fake_openclaw_callback
|
||||
|
||||
context = SimpleNamespace(user_data={})
|
||||
query = _FakeQuery("cb-no-id", "cmd:sales:2026/04/30", _FakeMessage(message_id=123))
|
||||
|
||||
_run(bot.handle_callback(_make_polling_update(query), context))
|
||||
_run(bot.handle_callback(_make_polling_update(query), context))
|
||||
|
||||
assert len(called) == 1
|
||||
assert called[0][2] == "cmd:sales:2026/04/30"
|
||||
|
||||
|
||||
def test_polling_callback_dedup_depends_on_message_id(monkeypatch):
|
||||
from services.telegram_bot_service import TrendTelegramBot
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_dedupe(_key, namespace="telegram_update"):
|
||||
if _key in seen:
|
||||
return True
|
||||
seen[_key] = True
|
||||
return False
|
||||
|
||||
bot = TrendTelegramBot(token="dummy")
|
||||
|
||||
called = []
|
||||
async def fake_openclaw_callback(*args):
|
||||
called.append(args)
|
||||
|
||||
monkeypatch.setattr(telegram_bot_service, "is_global_duplicate_update", fake_dedupe)
|
||||
bot._handle_openclaw_callback = fake_openclaw_callback
|
||||
|
||||
context = SimpleNamespace(user_data={})
|
||||
q1 = _FakeQuery("cb-no-id", "cmd:sales:2026/04/30", _FakeMessage(message_id=201))
|
||||
q2 = _FakeQuery("cb-no-id", "cmd:sales:2026/04/30", _FakeMessage(message_id=202))
|
||||
|
||||
_run(bot.handle_callback(_make_polling_update(q1), context))
|
||||
_run(bot.handle_callback(_make_polling_update(q2), context))
|
||||
|
||||
assert called == [
|
||||
(q1, context, "cmd:sales:2026/04/30"),
|
||||
(q2, context, "cmd:sales:2026/04/30"),
|
||||
]
|
||||
|
||||
|
||||
def test_polling_callback_dedup_with_same_query_id_different_update_id(monkeypatch):
|
||||
from services.telegram_bot_service import TrendTelegramBot
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_dedupe(_key, namespace="telegram_update"):
|
||||
if _key in seen:
|
||||
return True
|
||||
seen[_key] = True
|
||||
return False
|
||||
|
||||
bot = TrendTelegramBot(token="dummy")
|
||||
|
||||
called = []
|
||||
|
||||
async def fake_openclaw_callback(*args):
|
||||
called.append(args)
|
||||
|
||||
monkeypatch.setattr(telegram_bot_service, "is_global_duplicate_update", fake_dedupe)
|
||||
bot._handle_openclaw_callback = fake_openclaw_callback
|
||||
|
||||
context = SimpleNamespace(user_data={})
|
||||
q1 = _FakeQuery("cb-repeat", "cmd:sales:2026/04/30", _FakeMessage(message_id=301))
|
||||
q2 = _FakeQuery("cb-repeat", "cmd:sales:2026/04/30", _FakeMessage(message_id=301))
|
||||
|
||||
# 為了模擬 update_id 不同,帶入不同的 update 物件
|
||||
u1 = SimpleNamespace(callback_query=q1, effective_user=SimpleNamespace(id=777), update_id=30001)
|
||||
u2 = SimpleNamespace(callback_query=q2, effective_user=SimpleNamespace(id=777), update_id=30002)
|
||||
|
||||
_run(bot.handle_callback(u1, context))
|
||||
_run(bot.handle_callback(u2, context))
|
||||
|
||||
assert called == [(q1, context, "cmd:sales:2026/04/30")]
|
||||
|
||||
|
||||
def test_polling_callback_normalizes_legacy_menu_prefix(monkeypatch):
|
||||
from services.telegram_bot_service import TrendTelegramBot
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_dedupe(_key, namespace="telegram_update"):
|
||||
if _key in seen:
|
||||
return True
|
||||
seen[_key] = True
|
||||
return False
|
||||
|
||||
bot = TrendTelegramBot(token="dummy")
|
||||
normalized = []
|
||||
|
||||
async def fake_openclaw_callback(query, context, data):
|
||||
normalized.append(data)
|
||||
|
||||
monkeypatch.setattr(telegram_bot_service, "is_global_duplicate_update", fake_dedupe)
|
||||
bot._handle_openclaw_callback = fake_openclaw_callback
|
||||
|
||||
context = SimpleNamespace(user_data={})
|
||||
query = _FakeQuery("cb-menu", "menu_main", _FakeMessage(message_id=321))
|
||||
|
||||
_run(bot.handle_callback(_make_polling_update(query), context))
|
||||
|
||||
assert normalized == ["menu:main"]
|
||||
Reference in New Issue
Block a user