Fix OpenClaw callback command path from NL dispatch regression
All checks were successful
CD Pipeline / deploy (push) Successful in 4m17s

This commit is contained in:
OoO
2026-05-02 15:59:54 +08:00
parent 76304602b1
commit 673982d83b
4 changed files with 198 additions and 46 deletions

View File

@@ -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-Fixcallback 期間覆寫 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 hookfeature 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})

View File

@@ -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:

View File

@@ -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

View File

@@ -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)]