Fix OpenClaw callback command path from NL dispatch regression
All checks were successful
CD Pipeline / deploy (push) Successful in 4m17s
All checks were successful
CD Pipeline / deploy (push) Successful in 4m17s
This commit is contained in:
@@ -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})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user