From 673982d83bbbe3311f85b61b246b0858cb3a725e Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 15:59:54 +0800 Subject: [PATCH] Fix OpenClaw callback command path from NL dispatch regression --- routes/openclaw_bot_routes.py | 106 +++++++++++++--------- services/telegram_bot_service.py | 4 +- tests/test_openclaw_bot_routes_webhook.py | 99 ++++++++++++++++++++ tests/test_trend_telegram_bot_service.py | 35 +++++++ 4 files changed, 198 insertions(+), 46 deletions(-) diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index d127244..59d5c1a 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -24,6 +24,8 @@ import os import json import re import threading +from contextvars import ContextVar +from contextlib import contextmanager import requests from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify @@ -163,6 +165,7 @@ _RATE_LIMIT_PER_MIN = 30 # 每分鐘上限 _RATE_WINDOW_SEC = 60 # V-Fix:callback 期間覆寫 send_message 會跨 request 競態,全域鎖可避免重入干擾。 _CALLBACK_SEND_LOCK = threading.Lock() +_CMD_FROM_CALLBACK_CTX = ContextVar('openclaw_cmd_from_callback', default=False) def _check_rate_limit(user_id: int) -> bool: """回傳 True = 允許,False = 超過速率限制""" @@ -208,6 +211,16 @@ def _is_non_editable_message_error(response) -> bool: return any(m in desc for m in markers) +@contextmanager +def _run_with_callback_cmd_context(): + """在 callback 路徑處理指令時,暫時封鎖 NL agent dispatch。""" + token = _CMD_FROM_CALLBACK_CTX.set(True) + try: + yield + finally: + _CMD_FROM_CALLBACK_CTX.reset(token) + + def _build_callback_dedupe_key(update_id, cq_id, message_id=None, data=None, chat_id=None, user_id=None) -> str: """V-Fix:多維度組成 callback key,降低重複回報機率。""" key_parts = [] @@ -3600,11 +3613,12 @@ def query_monthly_summary(year: int, month: int) -> dict: prod_rows = c.execute(text(""" SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)) as rev, - SUM(CAST("數量" AS INTEGER)) as qty + SUM(CAST("數量" AS INTEGER)) as qty, + COUNT(DISTINCT "訂單編號") as orders FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) - GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 10 + GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 50 """), {'s': start_date, 'e': end_date}).fetchall() vendor_rows = c.execute(text(""" @@ -3629,7 +3643,8 @@ def query_monthly_summary(year: int, month: int) -> dict: 'products': row[3], 'days_with_data': row[4], 'daily': [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2])} for r in daily_rows], - 'top_products': [{'id': r[0], 'name': r[1], 'revenue': float(r[2]), 'qty': int(r[3])} + 'top_products': [{'id': r[0], 'name': r[1], 'revenue': float(r[2]), + 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)} for r in prod_rows], 'top_vendors': [{'name': r[0], 'revenue': float(r[1])} for r in vendor_rows], } @@ -4656,7 +4671,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): target = normalize_date(arg) if arg else ld # ADR-019 Phase 3: agent dispatch hook(feature flag 預設 OFF) - if _agent_dispatch_cmd(cmd, arg, chat_id, reply_to): + if (not _CMD_FROM_CALLBACK_CTX.get()) and _agent_dispatch_cmd(cmd, arg, chat_id, reply_to): return def _send_mcp_text_result(title: str, data, empty_message: str) -> bool: @@ -5653,52 +5668,53 @@ def telegram_webhook(): elif data.startswith('cmd:'): parts = data[4:].split(':', 1) - if cq_message_id: - _orig_send_message = send_message + with _run_with_callback_cmd_context(): + if cq_message_id: + _orig_send_message = send_message - def _callback_send_message( - _chat_id, - _text, - _reply_to=None, - _keyboard=None, - _parse_mode="Markdown", - **_kwargs, - ): - if _reply_to is None and "reply_to" in _kwargs: - _reply_to = _kwargs.pop("reply_to") - if "keyboard" in _kwargs: - _keyboard = _kwargs.pop("keyboard") - if "parse_mode" in _kwargs: - _parse_mode = _kwargs.pop("parse_mode") - - if _reply_to == cq_message_id: - result = edit_message_text( - _chat_id, - cq_message_id, - _text, - _keyboard, - _parse_mode, - ) - if not _should_fallback_send_message(result): - return result - - return _orig_send_message( + def _callback_send_message( _chat_id, _text, - _reply_to, - _keyboard, - _parse_mode, + _reply_to=None, + _keyboard=None, + _parse_mode="Markdown", **_kwargs, - ) + ): + if _reply_to is None and "reply_to" in _kwargs: + _reply_to = _kwargs.pop("reply_to") + if "keyboard" in _kwargs: + _keyboard = _kwargs.pop("keyboard") + if "parse_mode" in _kwargs: + _parse_mode = _kwargs.pop("parse_mode") - with _CALLBACK_SEND_LOCK: - try: - globals()['send_message'] = _callback_send_message - handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) - finally: - globals()['send_message'] = _orig_send_message - else: - handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) + if _reply_to == cq_message_id: + result = edit_message_text( + _chat_id, + cq_message_id, + _text, + _keyboard, + _parse_mode, + ) + if not _should_fallback_send_message(result): + return result + + return _orig_send_message( + _chat_id, + _text, + _reply_to, + _keyboard, + _parse_mode, + **_kwargs, + ) + + with _CALLBACK_SEND_LOCK: + try: + globals()['send_message'] = _callback_send_message + handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) + finally: + globals()['send_message'] = _orig_send_message + else: + handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) return jsonify({'ok': True}) diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index 7446f66..e557db4 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -636,10 +636,12 @@ class TrendTelegramBot: if data.startswith('cmd:'): from routes.openclaw_bot_routes import handle_cmd + from routes import openclaw_bot_routes as openclaw parts = data[4:].split(':', 1) await query.message.reply_chat_action(action='typing') - handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, reply_to) + with openclaw._run_with_callback_cmd_context(): + handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, reply_to) return except Exception as e: diff --git a/tests/test_openclaw_bot_routes_webhook.py b/tests/test_openclaw_bot_routes_webhook.py index f9a64be..8a6935d 100644 --- a/tests/test_openclaw_bot_routes_webhook.py +++ b/tests/test_openclaw_bot_routes_webhook.py @@ -247,6 +247,105 @@ def test_webhook_cmd_callback_updates_with_message_edit(monkeypatch): 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 diff --git a/tests/test_trend_telegram_bot_service.py b/tests/test_trend_telegram_bot_service.py index 10e1772..02c5a24 100644 --- a/tests/test_trend_telegram_bot_service.py +++ b/tests/test_trend_telegram_bot_service.py @@ -23,10 +23,14 @@ class _FakeMessage: self.chat_id = chat_id self.message_id = message_id self.replies = [] + self.edits = [] async def reply_text(self, text, **kwargs): self.replies.append((text, kwargs)) + async def reply_chat_action(self, action=None, **kwargs): + self.edits.append(("typing", action, kwargs)) + class _FakeQuery: def __init__(self, query_id, data, message): @@ -170,3 +174,34 @@ def test_polling_callback_normalizes_legacy_menu_prefix(monkeypatch): _run(bot.handle_callback(_make_polling_update(query), context)) assert normalized == ["menu:main"] + + +def test_polling_cmd_callback_uses_callback_context(monkeypatch): + from services.telegram_bot_service import TrendTelegramBot + from routes import openclaw_bot_routes as openclaw + + seen = {} + + def fake_dedupe(_key, namespace="telegram_update"): + if _key in seen: + return True + seen[_key] = True + return False + + called = [] + + def fake_handle_cmd(cmd, arg, chat_id, reply_to): + called.append((cmd, arg, chat_id, reply_to, openclaw._CMD_FROM_CALLBACK_CTX.get())) + return None + + bot = TrendTelegramBot(token="dummy") + + context = SimpleNamespace(user_data={}) + query = _FakeQuery("cb-context", "cmd:sales:2026/04/30", _FakeMessage(message_id=401)) + + monkeypatch.setattr(telegram_bot_service, "is_global_duplicate_update", fake_dedupe) + monkeypatch.setattr(openclaw, "handle_cmd", fake_handle_cmd) + + _run(bot.handle_callback(_make_polling_update(query), context)) + + assert called == [("sales", "2026/04/30", -200, 401, True)]