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_openclaw_answer_wakeup_query_returns_menu(): from routes import openclaw_bot_routes as bot text, kb = bot.openclaw_answer("小龍蝦") assert "OpenClaw(小O)" in text assert kb == bot.main_menu_keyboard() def test_openclaw_answer_variants_are_menu_wakeup(): from routes import openclaw_bot_routes as bot assert bot._looks_like_wakeup_prompt("小O_小龍蝦") is True assert bot._looks_like_wakeup_prompt("Hello, 小O") is True assert bot._looks_like_wakeup_prompt("今天業績") is False assert bot._looks_like_wakeup_prompt("你好") is True def test_webhook_clears_stale_user_context_at_request_start(): from routes import openclaw_bot_routes as bot bot._CURRENT_USER_ID_CTX.set(999) app = _build_request_app() with app.test_request_context("/bot/telegram/webhook", method="POST", json=None): bot.telegram_webhook() assert bot._CURRENT_USER_ID_CTX.get() is None def test_menu_command_returns_full_main_menu_keyboard(monkeypatch): from routes import openclaw_bot_routes as bot from services.openclaw_bot import menu_keyboards captured = {} def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown", **_kwargs): captured["chat_id"] = chat_id captured["text"] = text captured["keyboard"] = keyboard captured["parse_mode"] = parse_mode monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None) monkeypatch.setattr(bot, "send_message", fake_send_message) monkeypatch.setattr(bot, "BOT_USERNAME", "@KnownBot") monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True) app = _build_request_app() payload = { "update_id": 10002, "message": { "message_id": 56, "chat": {"id": -200, "type": "supergroup"}, "from": {"id": 777}, "text": "/menu", }, } with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload): bot.telegram_webhook() assert captured["chat_id"] == -200 assert captured["keyboard"] == menu_keyboards.main_menu_keyboard() assert captured["parse_mode"] == "Markdown" assert "OpenClaw(小O)" in captured["text"] 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_photo_message_uses_ollama_vision_before_gemini(monkeypatch): from routes import openclaw_bot_routes as bot sent = [] handled = [] class FakeResponse: def __init__(self, json_data=None, content=b"fake-image"): self._json_data = json_data or {} self.content = content def json(self): return self._json_data def fake_get(url, **_kwargs): if "getFile" in url: return FakeResponse({"result": {"file_path": "photos/product.jpg"}}) return FakeResponse(content=b"fake-image") monkeypatch.setattr(bot.requests, "get", fake_get) monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True) monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None) monkeypatch.setattr( bot, "send_message", lambda *args, **kwargs: sent.append((args, kwargs)), ) monkeypatch.setattr( bot, "handle_cmd", lambda cmd, arg, chat_id, reply_to: handled.append((cmd, arg, chat_id, reply_to)), ) monkeypatch.setattr( bot, "_identify_product_name_with_ollama_vision", lambda img_b64, request_id: "理膚寶水 B5 修復霜", ) monkeypatch.setattr( bot, "_identify_product_name_with_gemini_vision", lambda img_b64, request_id: (_ for _ in ()).throw(AssertionError("Gemini should not run first")), ) app = _build_request_app() payload = { "update_id": 10030, "message": { "message_id": 80, "chat": {"id": 777, "type": "private"}, "from": {"id": 777777}, "photo": [{"file_id": "small"}, {"file_id": "large"}], }, } with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload): bot.telegram_webhook() assert handled == [("competitor", "理膚寶水 B5 修復霜", 777, 80)] assert "理膚寶水 B5 修復霜" in sent[0][0][1] def test_photo_message_falls_back_to_gemini_when_ollama_empty(monkeypatch): from routes import openclaw_bot_routes as bot handled = [] calls = [] class FakeResponse: def __init__(self, json_data=None, content=b"fake-image"): self._json_data = json_data or {} self.content = content def json(self): return self._json_data def fake_get(url, **_kwargs): if "getFile" in url: return FakeResponse({"result": {"file_path": "photos/product.jpg"}}) return FakeResponse(content=b"fake-image") def fake_ollama(_img_b64, _request_id): calls.append("ollama") return "" def fake_gemini(_img_b64, _request_id): calls.append("gemini") return "飛利浦 Sonicare" monkeypatch.setattr(bot.requests, "get", fake_get) monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True) monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None) monkeypatch.setattr(bot, "send_message", lambda *args, **kwargs: None) monkeypatch.setattr( bot, "handle_cmd", lambda cmd, arg, chat_id, reply_to: handled.append((cmd, arg, chat_id, reply_to)), ) monkeypatch.setattr(bot, "_identify_product_name_with_ollama_vision", fake_ollama) monkeypatch.setattr(bot, "_identify_product_name_with_gemini_vision", fake_gemini) app = _build_request_app() payload = { "update_id": 10031, "message": { "message_id": 81, "chat": {"id": 777, "type": "private"}, "from": {"id": 777777}, "photo": [{"file_id": "small"}, {"file_id": "large"}], }, } with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload): bot.telegram_webhook() assert calls == ["ollama", "gemini"] assert handled == [("competitor", "飛利浦 Sonicare", 777, 81)] def test_obs_heal_audit_uses_current_callback_user(monkeypatch): from types import SimpleNamespace from routes import openclaw_bot_routes as bot import services.ollama_service as ollama_service import services.auto_heal_service as auto_heal_module captured = {} class FakeAutoHeal: def handle_exception(self, error_type, context): captured["error_type"] = error_type captured["context"] = context return SimpleNamespace(success=True, action="ALERT_ONLY", message="ok") sent = [] monkeypatch.setattr(bot, "send_message", lambda *args, **kwargs: sent.append((args, kwargs))) monkeypatch.setattr(ollama_service, "_is_unhealthy", lambda _host: True) monkeypatch.setattr(auto_heal_module, "auto_heal_service", FakeAutoHeal()) token = bot._CURRENT_USER_ID_CTX.set(777001) try: bot.handle_cmd("obs_heal", "GCP-A", -200, 55) finally: bot._CURRENT_USER_ID_CTX.reset(token) assert captured["error_type"] == "ollama_unhealthy" assert captured["context"]["triggered_by"] == "telegram_user_777001" assert sent 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_event_ignore_callback_edits_and_audits(monkeypatch): from routes import openclaw_bot_routes as bot edited = [] sent = [] audits = [] 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_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} def fake_audit(event_id, user_label, ts_label): audits.append((event_id, user_label, ts_label)) monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text) monkeypatch.setattr(bot, "send_message", fake_send_message) monkeypatch.setattr(bot, "_write_event_ignore_audit", fake_audit) 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": 10103, "callback_query": { "id": "cb-eig-1", "from": {"id": 777, "username": "alice"}, "message": { "message_id": 79, "chat": {"id": -200, "type": "supergroup"}, "text": "待處理事件", }, "data": "momo:eig:event-123", }, } with app.test_request_context( "/bot/telegram/webhook", method="POST", json=payload ): bot.telegram_webhook() assert audits and audits[0][0:2] == ("event-123", "alice") assert len(edited) == 1 _, _, text, keyboard, parse_mode = edited[0] assert "<b>待處理事件</b>" in text assert "已忽略" in text assert "alice" in text assert keyboard is None assert parse_mode == "HTML" assert sent == [] 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_text_command_uses_agent_dispatch_for_sales(monkeypatch): from routes import openclaw_bot_routes as bot dispatched = [] sent = [] queried = [] def fake_openclaw_answer(question, chat_id=None): dispatched.append((question, chat_id)) return ("NL response", None) def fake_query_sales(date): queried.append(date) return {'found': True, 'date': date} monkeypatch.setattr(bot, "openclaw_answer", fake_openclaw_answer) monkeypatch.setattr(bot, "_OPENCLAW_AGENT_DISPATCH_ENABLED", True) monkeypatch.setattr(bot, "_AGENT_DISPATCH_CMDS", {"sales"}) monkeypatch.setattr(bot, "send_message", lambda *_args, **_kwargs: sent.append(_args)) monkeypatch.setattr(bot, "query_sales", fake_query_sales) monkeypatch.setattr(bot, "_is_authorized", lambda _chat_type, _chat_id, _uid: True) monkeypatch.setattr(bot, "send_typing", lambda _chat_id: None) monkeypatch.setattr(bot, "BOT_USERNAME", "@KnownBot") app = _build_request_app() payload = { "update_id": 10011, "message": { "message_id": 123, "chat": {"id": -200, "type": "supergroup"}, "from": {"id": 777}, "text": "/sales 2026/04/30", }, } with app.test_request_context("/bot/telegram/webhook", method="POST", json=payload): bot.telegram_webhook() assert dispatched == [("請查 2026/04/30 的業績數字(包含營收、訂單數、毛利率)", -200)] assert queried == [] assert sent assert sent[0][1] == "NL response" def test_webhook_cmd_callback_stays_structured_when_dispatch_enabled(monkeypatch): from routes import openclaw_bot_routes as bot dispatched = [] queried_sales = [] edited = [] def fake_dispatch(cmd, arg, chat_id, reply_to): dispatched.append((cmd, arg, chat_id, reply_to)) return True def fake_query_sales(date): queried_sales.append(date) return {'found': False, 'date': date} def fake_edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="Markdown"): edited.append((chat_id, message_id, text)) return {"ok": True} def fake_send_message(chat_id, text, reply_to=None, keyboard=None, parse_mode="Markdown", **_kwargs): return {"ok": True} monkeypatch.setattr(bot, "_agent_dispatch_cmd", fake_dispatch) monkeypatch.setattr(bot, "_OPENCLAW_AGENT_DISPATCH_ENABLED", True) monkeypatch.setattr(bot, "_AGENT_DISPATCH_CMDS", {"sales"}) monkeypatch.setattr(bot, "edit_message_text", fake_edit_message_text) monkeypatch.setattr(bot, "send_message", fake_send_message) monkeypatch.setattr(bot, "query_sales", fake_query_sales) monkeypatch.setattr(bot, "query_top_products", lambda *_args, **_kwargs: []) 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": 10012, "callback_query": { "id": "cb-menu-sales", "from": {"id": 777}, "message": {"message_id": 444, "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 dispatched == [] assert queried_sales == ["2026/04/30"] assert len(edited) == 1 assert edited[0][0] == -200 assert edited[0][1] == 444 assert edited[0][2].startswith("⚠️ *查無資料*") 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)]