Files
ewoooc/tests/test_openclaw_bot_routes_webhook.py
OoO 1a886d962b
All checks were successful
CD Pipeline / deploy (push) Successful in 8m50s
fix(telegram): dedupe webhook+polling updates via shared DB guard
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>
2026-05-02 12:01:04 +08:00

549 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)]