[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:
ogt
2026-04-25 01:42:58 +08:00
parent fcac03379d
commit 3f7fc0aba0
3 changed files with 494 additions and 132 deletions

View File

@@ -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):
"""
批次定價決策 callbackADR-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 trailL2
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):
"""
事件忽略 callbackADR-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: