[V10.4-B] Telegram 按鈕安全強化:C2/C3/H4/H6 修復
修復 P9-1 全景盤點所發現的四項高優先問題:
- routes/openclaw_bot_routes.py:
C3: ALLOWED_USERS/ALLOWED_GROUP 白名單 fail-closed,阻擋非授權 chat
H4: _seen_update_ids 改用 deque(maxlen=500) LRU 防記憶體洩漏
- services/telegram_bot_service.py:
C2: 新增 momo:bpa/bpr/eig 三個 callback 分支 + handler 實作
H6: callback 滑動視窗速率限制(30次/分鐘/用戶)
- services/telegram_templates.py:
修正 decision_result / ops_action_result ImportError BLOCKER
新增 _now_taipei_hhmm / _html_escape 輔助函式
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,11 +17,31 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import time as _time_mod
|
||||
from typing import Optional
|
||||
from datetime import date, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# H6 修補:callback 速率限制(每 user_id 每分鐘最多 30 次 callback)
|
||||
# 訊息流在 routes/openclaw_bot_routes.py 已有 rate-limit,這裡補上 callback 缺口。
|
||||
_CB_RATE_LIMIT_PER_MIN = 30
|
||||
_CB_RATE_WINDOW_SEC = 60
|
||||
_cb_rate_tracker: dict = {} # {user_id: [timestamp, ...]}
|
||||
|
||||
def _check_cb_rate_limit(user_id: int) -> bool:
|
||||
"""回傳 True = 允許,False = 超過速率限制"""
|
||||
if user_id is None:
|
||||
return True # 無法辨識 user 時不阻擋(例如 inline query)
|
||||
now = _time_mod.time()
|
||||
window = _cb_rate_tracker.setdefault(user_id, [])
|
||||
# 清掉 60 秒以前的紀錄
|
||||
_cb_rate_tracker[user_id] = [t for t in window if now - t < _CB_RATE_WINDOW_SEC]
|
||||
if len(_cb_rate_tracker[user_id]) >= _CB_RATE_LIMIT_PER_MIN:
|
||||
return False
|
||||
_cb_rate_tracker[user_id].append(now)
|
||||
return True
|
||||
|
||||
# 嘗試匯入 telegram 模組
|
||||
try:
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
|
||||
@@ -32,7 +52,11 @@ except ImportError:
|
||||
logger.warning("python-telegram-bot 套件未安裝,Telegram Bot 功能將無法使用")
|
||||
|
||||
# 商品分類列表
|
||||
CATEGORIES = ['美妝', '3C', '服飾', '居家', '母嬰', '電商', '優惠', '生活', '美食', '熱門']
|
||||
CATEGORIES = [
|
||||
'美妝保養', '3C家電', '服飾配件', '居家生活', '母嬰用品',
|
||||
'生鮮食品', '圖書文具', '戶外運動', '餐券票券', '醫療保健',
|
||||
'美體保健', '寵物用品', '箱包精品', '車類百貨', '情趣用品'
|
||||
]
|
||||
|
||||
|
||||
class TrendTelegramBot:
|
||||
@@ -402,6 +426,19 @@ class TrendTelegramBot:
|
||||
async def handle_callback(self, update: Update, context):
|
||||
"""處理按鈕回調"""
|
||||
query = update.callback_query
|
||||
|
||||
# H6: per-user rate-limit(每分鐘 30 次)。
|
||||
# 放在 query.answer() 之前;若本檔未來加入授權檢查,應置於授權之後以免讓
|
||||
# 未授權 user 佔用 rate counter(目前本檔無授權層,由 python-telegram-bot
|
||||
# Application 自行處理 token 範圍信任)。
|
||||
_uid = query.from_user.id if query.from_user else None
|
||||
if not _check_cb_rate_limit(_uid):
|
||||
try:
|
||||
await query.answer("操作太頻繁,請稍後再試", show_alert=False)
|
||||
except Exception as _e:
|
||||
logger.debug(f"[handle_callback] rate-limit answer failed: {_e}")
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
data = query.data
|
||||
|
||||
@@ -488,6 +525,17 @@ class TrendTelegramBot:
|
||||
elif data.startswith("momo:ops:"):
|
||||
await self._handle_ops_callback(query, data)
|
||||
|
||||
# ===== 批次定價決策(momo:bpa / bpr:<batch_id>)=====
|
||||
elif data.startswith("momo:bpa:"):
|
||||
await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "approve")
|
||||
|
||||
elif data.startswith("momo:bpr:"):
|
||||
await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "reject")
|
||||
|
||||
# ===== 事件忽略(momo:eig:<event_id>)=====
|
||||
elif data.startswith("momo:eig:"):
|
||||
await self._handle_event_ignore(query, data.split(":", 2)[-1])
|
||||
|
||||
# ===== OpenClaw 指令按鈕(cmd:<cmd>:<arg>)=====
|
||||
elif data.startswith("cmd:"):
|
||||
parts = data[4:].split(":", 1)
|
||||
@@ -579,11 +627,12 @@ class TrendTelegramBot:
|
||||
[{'text': '📅 每週業績', 'callback_data': 'cmd:trend:week'},
|
||||
{'text': '📅 每月業績', 'callback_data': 'cmd:history:' + current_month}],
|
||||
[{'text': '📅 每季業績', 'callback_data': 'cmd:trend:quarter'},
|
||||
{'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}],
|
||||
{'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}],
|
||||
[{'text': '📈 趨勢分析', 'callback_data': 'menu:trend'},
|
||||
{'text': '🔄 同期比較', 'callback_data': 'cmd:compare:' + today}],
|
||||
[{'text': '🗂 分類業績', 'callback_data': 'cmd:category:' + today},
|
||||
{'text': '📅 日期/區間', 'callback_data': 'await:date_range_sales'}],
|
||||
[{'text': '🗃 月份覽', 'callback_data': 'cmd:history'}],
|
||||
[{'text': '← 返回主選單', 'callback_data': 'menu:main'}],
|
||||
]
|
||||
|
||||
@@ -636,16 +685,30 @@ class TrendTelegramBot:
|
||||
def _get_reports_submenu(self):
|
||||
"""簡報報表子選單"""
|
||||
return [
|
||||
[{'text': '📄 日報', 'callback_data': 'cmd:ppt:daily'},
|
||||
{'text': '📊 週報', 'callback_data': 'cmd:ppt:weekly'}],
|
||||
[{'text': '📈 月報', 'callback_data': 'cmd:ppt:monthly'},
|
||||
# ── 定期報告
|
||||
[{'text': '📄 日報', 'callback_data': 'cmd:ppt:daily'},
|
||||
{'text': '📈 週報', 'callback_data': 'cmd:ppt:weekly'}],
|
||||
[{'text': '📅 月報', 'callback_data': 'cmd:ppt:monthly'},
|
||||
{'text': '📋 下載報表','callback_data': 'cmd:report'}],
|
||||
# ── 策略簡報
|
||||
[{'text': '🧩 策略(日)', 'callback_data': 'cmd:ppt:strategy'},
|
||||
{'text': '🧩 策略(週)', 'callback_data': 'cmd:ppt:strategy weekly'}],
|
||||
[{'text': '🧩 策略(月)', 'callback_data': 'cmd:ppt:strategy monthly'},
|
||||
{'text': '🧩 策略(季)', 'callback_data': 'cmd:ppt:strategy quarterly'}],
|
||||
[{'text': '🧩 策略(年)', 'callback_data': 'cmd:ppt:strategy yearly'},
|
||||
{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'}],
|
||||
[{'text': '🔍 競品比較', 'callback_data': 'menu:competitor'},
|
||||
{'text': '📈 成長趨勢報告', 'callback_data': 'cmd:ppt:growth'}],
|
||||
[{'text': '🏭 廠商業績報告', 'callback_data': 'cmd:ppt:vendor'},
|
||||
{'text': '← 返回主選單', 'callback_data': 'menu:main'}],
|
||||
[{'text': '🧩 策略(半年)','callback_data': 'cmd:ppt:strategy half'},
|
||||
{'text': '🧩 策略(年)', 'callback_data': 'cmd:ppt:strategy yearly'}],
|
||||
# ── 促銷 & 競品
|
||||
[{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'},
|
||||
{'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}],
|
||||
# ── 新增:成長趨勢 / 廠商 / BCG
|
||||
[{'text': '📈 成長趨勢報告', 'callback_data': 'cmd:ppt:growth'},
|
||||
{'text': '🏭 廠商業績報告', 'callback_data': 'cmd:ppt:vendor'}],
|
||||
[{'text': '🎯 BCG 品牌矩陣', 'callback_data': 'cmd:ppt:bcg'},
|
||||
{'text': '📅 指定月份廠商', 'callback_data': 'await:date_ppt_vendor'}],
|
||||
# ── 自訂
|
||||
[{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'},
|
||||
{'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}],
|
||||
[{'text': '← 返回主選單', 'callback_data': 'menu:main'}],
|
||||
]
|
||||
|
||||
def _get_market_submenu(self):
|
||||
@@ -653,14 +716,14 @@ class TrendTelegramBot:
|
||||
return [
|
||||
[{'text': '📰 電商新聞', 'callback_data': 'cmd:news'},
|
||||
{'text': '🌤 台北天氣', 'callback_data': 'cmd:weather'}],
|
||||
[{'text': '<EFBFBD> Google熱搜', 'callback_data': 'cmd:trends'},
|
||||
{'text': '<EFBFBD> Dcard口碑', 'callback_data': 'cmd:dcard'}],
|
||||
[{'text': '🔥 Google熱搜', 'callback_data': 'cmd:trends'},
|
||||
{'text': '💬 Dcard口碑', 'callback_data': 'cmd:dcard'}],
|
||||
[{'text': '💱 台銀匯率', 'callback_data': 'cmd:exchange'},
|
||||
{'text': '📅 電商節慶', 'callback_data': 'cmd:calendar'}],
|
||||
[{'text': '▶️ YouTube爆紅商品', 'callback_data': 'cmd:youtube'},
|
||||
{'text': '🧠 AI學習狀態', 'callback_data': 'cmd:learn'}],
|
||||
[{'text': '🔍 關鍵字比價', 'callback_data': 'await:search_compare'},
|
||||
{'text': '<EFBFBD> 圖片比價說明', 'callback_data': 'cmd:photo_search_help'}],
|
||||
{'text': '📷 圖片比價說明', 'callback_data': 'cmd:photo_search_help'}],
|
||||
[{'text': '← 返回主選單', 'callback_data': 'menu:main'}],
|
||||
]
|
||||
|
||||
@@ -676,11 +739,11 @@ class TrendTelegramBot:
|
||||
|
||||
return [
|
||||
[{'text': f'📊 今日簡報 ({td_label})', 'callback_data': f'cmd:ppt:competitor {td_str}'},
|
||||
{'text': f'<EFBFBD> 昨日簡報 ({yd_label})', 'callback_data': f'cmd:ppt:competitor {yd_str}'}],
|
||||
{'text': f'📊 昨日報 ({yd_label})', 'callback_data': f'cmd:ppt:competitor {yd_str}'}],
|
||||
[{'text': '📈 本週比較', 'callback_data': 'cmd:ppt:competitor weekly'},
|
||||
{'text': '📆 本月比較', 'callback_data': 'cmd:ppt:competitor monthly'}],
|
||||
[{'text': '🗃 本季比較', 'callback_data': 'cmd:ppt:competitor quarterly'},
|
||||
{'text': '<EFBFBD> 指定日期', 'callback_data': 'await:date_competitor'}],
|
||||
{'text': '📅 指定日期', 'callback_data': 'await:date_competitor'}],
|
||||
[{'text': '📄 更多週期 →', 'callback_data': 'menu:competitor_ppt'}],
|
||||
[{'text': '← 返回主選單', 'callback_data': 'menu:main'}],
|
||||
]
|
||||
@@ -689,7 +752,7 @@ class TrendTelegramBot:
|
||||
"""競品 PPT 長週期選單(第三層)— 半年/年;日/週/月/季已在第二層"""
|
||||
return [
|
||||
[{'text': '📆 半年比較', 'callback_data': 'cmd:ppt:competitor half'},
|
||||
{'text': '<EFBFBD> 年比較', 'callback_data': 'cmd:ppt:competitor yearly'}],
|
||||
{'text': '🗓 年比較', 'callback_data': 'cmd:ppt:competitor yearly'}],
|
||||
[{'text': '← 返回競品日報', 'callback_data': 'menu:competitor'}],
|
||||
]
|
||||
|
||||
@@ -846,6 +909,130 @@ class TrendTelegramBot:
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
async def _handle_batch_price_decision(self, query, batch_id: str, action: str):
|
||||
"""
|
||||
批次定價決策 callback(ADR-012 C2 修復)
|
||||
callback_data: momo:bpa:<batch_id> / momo:bpr:<batch_id>
|
||||
流程:(1) 走 event_router.dispatch 走 L2 audit;(2) 寫 KM feedback;(3) 編輯訊息
|
||||
"""
|
||||
from services.openclaw_learning_service import store_insight
|
||||
from services.event_router import dispatch as event_dispatch
|
||||
from datetime import date as date_cls, datetime
|
||||
|
||||
user = query.from_user
|
||||
operator = user.full_name or f"id_{user.id}"
|
||||
action_label = "批准" if action == "approve" else "拒絕"
|
||||
|
||||
# 1) EventRouter dispatch — audit trail(L2)
|
||||
try:
|
||||
await event_dispatch({
|
||||
"event_type": "batch_price_decision",
|
||||
"severity": "alert",
|
||||
"source": "telegram_bot",
|
||||
"title": f"批次定價 {action_label}",
|
||||
"summary": f"batch_id={batch_id} by {operator}",
|
||||
"metadata": {
|
||||
"batch_id": batch_id,
|
||||
"decision": action,
|
||||
"operator": operator,
|
||||
"operator_tg_id": user.id,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[TelegramBot] batch_price event_dispatch failed: {e}")
|
||||
|
||||
# 2) KM feedback(保守/積極策略訓練數據)
|
||||
try:
|
||||
note = "訓練積極定價策略" if action == "approve" else "訓練保守定價策略"
|
||||
store_insight(
|
||||
insight_type="batch_price_decision_feedback",
|
||||
content=f"管理員{action_label}批次定價(batch_id={batch_id}),{note}",
|
||||
period=date_cls.today().isoformat(),
|
||||
metadata={
|
||||
"decision": action,
|
||||
"batch_id": batch_id,
|
||||
"operator": operator,
|
||||
"operator_tg_id": user.id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[TelegramBot] batch_price store_insight failed: {e}")
|
||||
await query.answer("決策已記錄但 KM 寫入失敗", show_alert=True)
|
||||
return
|
||||
|
||||
# 3) ack + 編輯原訊息加上執行紀錄
|
||||
await query.answer(f"已{action_label}批次決策", show_alert=False)
|
||||
ts = datetime.now().strftime("%H:%M")
|
||||
icon = "✅" if action == "approve" else "❌"
|
||||
footer = f"\n\n━━━━━━━━━━━━━━━━━━━━\n{icon} 已{action_label} by {operator} at {ts}"
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
(query.message.text or "") + footer,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[TelegramBot] batch_price edit_message failed: {e}")
|
||||
|
||||
async def _handle_event_ignore(self, query, event_id: str):
|
||||
"""
|
||||
事件忽略 callback(ADR-012 C2 修復)
|
||||
callback_data: momo:eig:<event_id>
|
||||
流程:寫 KM + event_router 留痕 + 編輯訊息
|
||||
"""
|
||||
from services.openclaw_learning_service import store_insight
|
||||
from services.event_router import dispatch as event_dispatch
|
||||
from datetime import date as date_cls, datetime
|
||||
|
||||
user = query.from_user
|
||||
operator = user.full_name or f"id_{user.id}"
|
||||
|
||||
# 1) EventRouter — L0 留痕(severity=info,不再觸發 AI 分析)
|
||||
try:
|
||||
await event_dispatch({
|
||||
"event_type": "event_ignored",
|
||||
"severity": "info",
|
||||
"source": "telegram_bot",
|
||||
"title": f"事件 {event_id} 已忽略",
|
||||
"summary": f"操作員 {operator} 手動忽略告警事件",
|
||||
"metadata": {
|
||||
"event_id": event_id,
|
||||
"operator": operator,
|
||||
"operator_tg_id": user.id,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[TelegramBot] event_ignore dispatch failed: {e}")
|
||||
|
||||
# 2) KM 訓練數據 —— 幫 L1/L2 學習哪類事件不需打擾
|
||||
try:
|
||||
store_insight(
|
||||
insight_type="event_ignore_feedback",
|
||||
content=f"管理員忽略事件(event_id={event_id}),訓練降噪策略",
|
||||
period=date_cls.today().isoformat(),
|
||||
metadata={
|
||||
"event_id": event_id,
|
||||
"decision": "ignore",
|
||||
"operator": operator,
|
||||
"operator_tg_id": user.id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[TelegramBot] event_ignore store_insight failed: {e}")
|
||||
await query.answer("決策已發送但 KM 寫入失敗", show_alert=True)
|
||||
return
|
||||
|
||||
# 3) ack + 編輯訊息
|
||||
await query.answer("已忽略此事件", show_alert=False)
|
||||
ts = datetime.now().strftime("%H:%M")
|
||||
footer = f"\n\n━━━━━━━━━━━━━━━━━━━━\n🛑 已忽略 by {operator} at {ts}"
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
(query.message.text or "") + footer,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[TelegramBot] event_ignore edit_message failed: {e}")
|
||||
|
||||
async def _show_trend_by_category(self, query, category: str):
|
||||
"""顯示指定分類的趨勢"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user