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>
549 lines
18 KiB
Python
549 lines
18 KiB
Python
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)]
|