Files
ewoooc/services/telegram_bot_service.py
OoO 00591c5489 feat(ea-hitl): ADR-021 EA 升級審核 pre-fetch + 競價告警金額影響量化
根治 2026-05-02 統帥反映的三層 EA escalation 訊息空泛問題:

1. _escalate_to_human 對 price_drop_alert / market_opportunity /
   threat_escalation 三類觸發,送 Telegram 前先 await Hermes 取具體
   SKU 清單覆蓋 plan 元流程文字(5s 短超時,失敗 fallback 原 plan)
2. NemoTron 競價告警新增 _compute_business_impact helper:
   過去 7 日營收流失(gap_pct>0 才算)+ 跟進競品建議價,
   dispatch 主路徑 / 防線二 / Hermes rule fallback 三條全部 Python
   獨裁注入,告警含「📉 NT$ X」「🎯 NT$ Y」具體金額
3. 補實 telegram_bot_service.handle_callback 的 momo:eig: prefix
   handler,HITL「🛑 忽略此事件」按鈕首次有對應 audit 寫入

Critic 審查通過(5 項必修全綠):
- Critical-1: user_label HTML escape 防 Telegram username XSS
- High-1: pre-fetch 改 asyncio.wait_for(5s) 防阻塞 escalation
- High-2: 全部行缺金額時 return None 觸發 plan fallback
- Medium-2: 空 event_id callback 拒絕避免 audit 污染
- Medium-3: gap_pct≤0 時 revenue_loss_7d 強制歸 0 不誤導降價

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:03:38 +08:00

1391 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram Bot 服務
提供趨勢資料的 Telegram 互動功能
注意: 需要安裝 python-telegram-bot 套件
pip install python-telegram-bot>=20.0
功能:
- 主選單按鈕介面
- 分類選擇按鈕
- 趨勢查詢、AI 搜尋、文案生成
- 每日趨勢摘要推播
"""
import os
import json
import asyncio
import logging
import requests
from typing import Optional
from datetime import date, datetime, timedelta
from services.telegram_update_guard import is_duplicate_update as is_global_duplicate_update
logger = logging.getLogger(__name__)
# 嘗試匯入 telegram 模組
try:
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ConversationHandler
TELEGRAM_AVAILABLE = True
except ImportError:
TELEGRAM_AVAILABLE = False
logger.warning("python-telegram-bot 套件未安裝Telegram Bot 功能將無法使用")
# 商品分類列表
CATEGORIES = ['美妝', '3C', '服飾', '居家', '母嬰', '電商', '優惠', '生活', '美食', '熱門']
class TrendTelegramBot:
"""趨勢資料庫 Telegram Bot 服務"""
@staticmethod
def _bool_env(name: str) -> bool:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
def should_run_polling(self) -> bool:
"""決定是否啟動 polling若已設定 webhook預設不啟動 polling。"""
if self._bool_env("TELEGRAM_FORCE_POLLING"):
logger.info("[TrendTelegramBot] 強制啟用 pollingTELEGRAM_FORCE_POLLING=1")
return True
if self._bool_env("TELEGRAM_DISABLE_POLLING"):
logger.warning("[TrendTelegramBot] 停用 pollingTELEGRAM_DISABLE_POLLING=1")
return False
if not self.token:
logger.error("Telegram Token 未設定,無法判斷 webhook 狀態")
return False
try:
payload = requests.get(
f"https://api.telegram.org/bot{self.token}/getWebhookInfo",
timeout=5,
).json()
if not payload.get("ok"):
return True
return not bool((payload.get("result") or {}).get("url"))
except Exception as exc:
logger.warning(f"檢查 webhook 狀態失敗,預設不啟動 polling除非 TELEGRAM_FORCE_POLLING=1{exc}")
return self._bool_env("TELEGRAM_FORCE_POLLING")
def __init__(self, token: str = None):
"""
初始化 Bot
Args:
token: Telegram Bot Token (從 BotFather 取得)
"""
self.token = token or os.getenv('TELEGRAM_BOT_TOKEN')
self.application = None
self.is_running = False
if not TELEGRAM_AVAILABLE:
logger.error("Telegram Bot 無法初始化: 缺少 python-telegram-bot 套件")
return
if not self.token:
logger.warning("Telegram Bot Token 未設定")
async def start(self):
"""啟動 Bot"""
if not TELEGRAM_AVAILABLE or not self.token:
logger.error("無法啟動 Telegram Bot")
return False
if not self.should_run_polling():
logger.warning("檢測到 webhook 已啟用,跳過 polling 模式啟動")
return False
try:
self.application = Application.builder().token(self.token).build()
# 註冊指令處理器
self.application.add_handler(CommandHandler("start", self.cmd_start))
self.application.add_handler(CommandHandler("menu", self.cmd_menu))
self.application.add_handler(CommandHandler("help", self.cmd_help))
self.application.add_handler(CommandHandler("trend", self.cmd_trend))
self.application.add_handler(CommandHandler("search", self.cmd_search))
self.application.add_handler(CommandHandler("copy", self.cmd_copy))
self.application.add_handler(CommandHandler("keywords", self.cmd_keywords))
self.application.add_handler(CommandHandler("daily", self.cmd_daily))
# 回調查詢處理器
self.application.add_handler(CallbackQueryHandler(self.handle_callback))
# 一般訊息處理器
self.application.add_handler(MessageHandler(
filters.TEXT & ~filters.COMMAND,
self.handle_message
))
# 啟動
await self.application.initialize()
await self.application.start()
await self.application.updater.start_polling()
self.is_running = True
logger.info("Telegram Bot 已啟動")
return True
except Exception as e:
logger.error(f"Telegram Bot 啟動失敗: {e}")
return False
async def stop(self):
"""停止 Bot"""
if self.application and self.is_running:
try:
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()
self.is_running = False
logger.info("Telegram Bot 已停止")
except Exception as e:
logger.error(f"Telegram Bot 停止失敗: {e}")
# ========== 指令處理器 ==========
def _get_main_menu_keyboard(self):
"""建立主選單按鈕"""
keyboard = [
[
InlineKeyboardButton("📊 熱門趨勢", callback_data="menu_trend"),
InlineKeyboardButton("🔍 AI 搜尋", callback_data="menu_search"),
],
[
InlineKeyboardButton("✍️ 生成文案", callback_data="menu_copy"),
InlineKeyboardButton("🏷️ 熱門關鍵字", callback_data="menu_keywords"),
],
[
InlineKeyboardButton("📰 每日摘要", callback_data="menu_daily"),
InlineKeyboardButton("⚙️ 設定", callback_data="menu_settings"),
],
]
return InlineKeyboardMarkup(keyboard)
def _get_category_keyboard(self, callback_prefix: str = "cat"):
"""建立分類選擇按鈕"""
# 每行 3 個按鈕
keyboard = []
row = []
for i, cat in enumerate(CATEGORIES):
row.append(InlineKeyboardButton(cat, callback_data=f"{callback_prefix}_{cat}"))
if len(row) == 3:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
# 加入返回按鈕
keyboard.append([InlineKeyboardButton("🔙 返回主選單", callback_data="menu_main")])
return InlineKeyboardMarkup(keyboard)
def _to_inline_markup(self, keyboard):
"""將 OpenClaw dict keyboard 轉成 python-telegram-bot markup。"""
if not keyboard:
return None
return InlineKeyboardMarkup([
[
InlineKeyboardButton(
text=button['text'],
callback_data=button['callback_data'],
)
for button in row
]
for row in keyboard
])
async def cmd_menu(self, update: Update, context):
"""顯示 OpenClaw 完整主選單。"""
from routes import openclaw_bot_routes as openclaw
await update.message.reply_text(
"👋 *OpenClaw小O* — 電商智能助理\n\n"
"點下方按鈕,或直接用中文跟我說話 👇",
parse_mode='Markdown',
reply_markup=self._to_inline_markup(openclaw.main_menu_keyboard()),
)
async def cmd_start(self, update: Update, context):
"""開始指令 - 顯示主選單"""
user = update.effective_user
await update.message.reply_text(
f"👋 嗨 {user.first_name}\n\n"
f"我是 *MOMO 趨勢助手 Bot*\n"
f"請選擇要執行的功能:",
parse_mode='Markdown',
reply_markup=self._get_main_menu_keyboard()
)
async def cmd_help(self, update: Update, context):
"""說明指令"""
help_text = """
📖 *指令說明*
*趨勢查詢*
/trend - 查看所有分類熱門趨勢
/trend 美妝 - 查看美妝分類趨勢
*AI 搜尋*
/search 夏季防曬推薦 - AI 搜尋並分析
*文案生成*
/copy 防曬乳 - 為商品生成行銷文案
*關鍵字*
/keywords - 近 7 天熱門關鍵字
*每日摘要*
/daily - 查看今日趨勢摘要
"""
await update.message.reply_text(help_text, parse_mode='Markdown')
async def cmd_trend(self, update: Update, context):
"""趨勢查詢指令"""
category = ' '.join(context.args) if context.args else None
await update.message.reply_text("🔄 正在查詢趨勢資料...")
try:
from database.trend_models import TrendRecord
from database.manager import get_session
session = get_session()
date_from = date.today() - timedelta(days=7)
# 查詢熱門趨勢
query = session.query(TrendRecord).filter(
TrendRecord.trend_date >= date_from
)
if category:
query = query.filter(TrendRecord.category == category)
records = query.order_by(
TrendRecord.popularity_score.desc()
).limit(10).all()
session.close()
if not records:
await update.message.reply_text(
f"📭 {f'{category}分類' if category else ''}近 7 天沒有趨勢資料"
)
return
# 格式化回覆
title = f"📊 {category if category else '所有分類'}熱門趨勢 (近7天)\n\n"
lines = []
for i, r in enumerate(records, 1):
source_emoji = {
'ptt': '💬',
'dcard': '📱',
'google_news': '📰',
'youtube': '🎬'
}.get(r.source, '📄')
lines.append(
f"{i}. {source_emoji} {r.title[:30]}{'...' if len(r.title) > 30 else ''} "
f"(熱度:{r.popularity_score})"
)
await update.message.reply_text(title + '\n'.join(lines))
except Exception as e:
logger.error(f"Telegram cmd_trend 失敗: {e}")
await update.message.reply_text(f"❌ 查詢失敗: {str(e)}")
async def cmd_search(self, update: Update, context):
"""AI 搜尋指令"""
query = ' '.join(context.args)
if not query:
await update.message.reply_text("❓ 請輸入搜尋關鍵字\n範例: /search 夏季防曬推薦")
return
await update.message.reply_text(f"🔍 正在搜尋「{query}」...")
try:
from services.trend_crawler_service import get_trend_crawler_service
service = get_trend_crawler_service()
result = service.web_search_with_cache(query, search_type='trends')
if result['success']:
data = result['data']
parsed = data.get('result', {})
summary = parsed.get('summary', '無摘要')
results = parsed.get('results', [])[:5]
reply = f"🔍 *搜尋結果: {query}*\n\n"
reply += f"📝 *摘要:*\n{summary}\n\n"
if results:
reply += "*相關資訊:*\n"
for i, r in enumerate(results, 1):
title = r.get('title', '無標題')[:40]
reply += f"{i}. {title}\n"
await update.message.reply_text(reply, parse_mode='Markdown')
else:
await update.message.reply_text(f"❌ 搜尋失敗: {result.get('error', '未知錯誤')}")
except Exception as e:
logger.error(f"Telegram cmd_search 失敗: {e}")
await update.message.reply_text(f"❌ 搜尋失敗: {str(e)}")
async def cmd_copy(self, update: Update, context):
"""文案生成指令"""
if not context.args:
await update.message.reply_text("❓ 請輸入商品名稱\n範例: /copy 防曬乳")
return
product_name = context.args[0]
context_hint = ' '.join(context.args[1:]) if len(context.args) > 1 else None
await update.message.reply_text(f"✍️ 正在為「{product_name}」生成文案...")
try:
from services.ollama_service import OllamaService
ollama = OllamaService()
prompt = f"""請為以下商品生成 3 種風格的行銷文案:
商品: {product_name}
{f'情境: {context_hint}' if context_hint else ''}
請分別生成:
1. 標準版 (100字內)
2. 活潑版 (含表情符號100字內)
3. 限時版 (強調緊迫感100字內)
以繁體中文回覆。"""
response = ollama.generate(prompt, temperature=0.7)
if response.success:
await update.message.reply_text(
f"✨ *{product_name} 行銷文案*\n\n{response.content}",
parse_mode='Markdown'
)
else:
await update.message.reply_text(f"❌ 生成失敗: {response.error}")
except Exception as e:
logger.error(f"Telegram cmd_copy 失敗: {e}")
await update.message.reply_text(f"❌ 生成失敗: {str(e)}")
async def cmd_keywords(self, update: Update, context):
"""熱門關鍵字指令"""
category = ' '.join(context.args) if context.args else None
try:
from database.trend_models import TrendKeyword
from database.manager import get_session
from sqlalchemy import func
session = get_session()
date_from = date.today() - timedelta(days=7)
query = session.query(
TrendKeyword.keyword,
func.sum(TrendKeyword.mention_count).label('total')
).filter(
TrendKeyword.trend_date >= date_from
).group_by(TrendKeyword.keyword)
if category:
query = query.filter(TrendKeyword.category == category)
keywords = query.order_by(
func.sum(TrendKeyword.mention_count).desc()
).limit(20).all()
session.close()
if not keywords:
await update.message.reply_text("📭 近 7 天沒有熱門關鍵字資料")
return
title = f"🏷️ {category if category else '所有分類'}熱門關鍵字 (近7天)\n\n"
lines = [f"{i}. {kw.keyword} ({kw.total}次)" for i, kw in enumerate(keywords, 1)]
await update.message.reply_text(title + '\n'.join(lines))
except Exception as e:
logger.error(f"Telegram cmd_keywords 失敗: {e}")
await update.message.reply_text(f"❌ 查詢失敗: {str(e)}")
async def cmd_daily(self, update: Update, context):
"""每日趨勢摘要"""
try:
from database.trend_models import TrendAnalysis
from database.manager import get_session
session = get_session()
analysis = session.query(TrendAnalysis).filter(
TrendAnalysis.analysis_date == date.today(),
TrendAnalysis.analysis_type == 'daily_summary'
).first()
session.close()
if not analysis:
await update.message.reply_text("📭 今日尚無趨勢分析報告")
return
hot_keywords = json.loads(analysis.hot_keywords or '[]')
marketing = json.loads(analysis.marketing_suggestions or '[]')
reply = f"📰 *今日趨勢摘要*\n\n"
reply += f"📝 *概況:*\n{analysis.summary}\n\n"
if hot_keywords:
reply += f"🏷️ *熱門關鍵字:*\n{', '.join(hot_keywords[:10])}\n\n"
if marketing:
reply += f"💡 *行銷建議:*\n"
for i, m in enumerate(marketing[:5], 1):
reply += f"{i}. {m}\n"
await update.message.reply_text(reply, parse_mode='Markdown')
except Exception as e:
logger.error(f"Telegram cmd_daily 失敗: {e}")
await update.message.reply_text(f"❌ 查詢失敗: {str(e)}")
async def handle_callback(self, update: Update, context):
"""處理按鈕回調"""
query = update.callback_query
data = query.data or ''
def _build_polling_callback_dedupe_key():
update_id = getattr(update, "update_id", None)
msg = getattr(query, "message", None)
msg_id = getattr(msg, "message_id", None)
chat_id = getattr(msg, "chat_id", None) if msg is not None else None
user_id = getattr(getattr(update, "effective_user", None), "id", None)
if user_id is None:
user_id = getattr(getattr(query, "from_user", None), "id", None)
parts = []
if update_id is not None:
if query.id:
parts.append(f"cbq:{query.id}")
else:
parts.append(f"uid:{update_id}")
elif query.id:
parts.append(f"cbq:{query.id}")
if chat_id is not None:
parts.append(f"chat:{chat_id}")
if user_id is not None:
parts.append(f"user:{user_id}")
if msg_id is not None:
parts.append(f"msg:{msg_id}")
data_key = data or ""
if data_key:
parts.append(f"data:{data_key}")
return "cb:" + "|".join(parts) if parts else f"cb-query:{query.id}"
try:
from routes import openclaw_bot_routes as openclaw
if data.startswith('menu_'):
key = data[5:]
if key in openclaw._SUBMENUS:
data = f"menu:{key}"
elif data.startswith('await_'):
key = data[6:]
if key in openclaw._AWAIT_PROMPTS:
data = f"await:{key}"
elif data.startswith('cmd_'):
data = f"cmd:{data[4:]}"
except Exception:
pass
dedupe_key = _build_polling_callback_dedupe_key()
if is_global_duplicate_update(dedupe_key, namespace="telegram_update"):
logger.warning(f"忽略重複 callback key={dedupe_key}")
try:
await query.answer()
except Exception as exc:
logger.debug(f"callback 重複回覆失敗: {exc}")
return
await query.answer()
# ADR-012 / A' 軌HITL escalation 按鈕momo:eig:{event_id} = event_ignore
# triaged_alert 鍵盤按鈕「🛑 忽略此事件」會 callback momo:eig:<id>
if data.startswith('momo:eig:'):
await self._handle_event_ignore_callback(query, data)
return
if data.startswith(('menu:', 'cmd:', 'await:')):
await self._handle_openclaw_callback(query, context, data)
return
# ===== 主選單按鈕 =====
if data == "menu_main":
await query.edit_message_text(
"請選擇要執行的功能:",
reply_markup=self._get_main_menu_keyboard()
)
elif data == "menu_trend":
await query.edit_message_text(
"📊 *熱門趨勢*\n\n請選擇分類:",
parse_mode='Markdown',
reply_markup=self._get_category_keyboard("trend")
)
elif data == "menu_search":
context.user_data['waiting_for'] = 'search_query'
await query.edit_message_text(
"🔍 *AI 搜尋*\n\n請輸入要搜尋的關鍵字:\n\n"
"例如:夏季防曬推薦、母親節禮物",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回主選單", callback_data="menu_main")
]])
)
elif data == "menu_copy":
context.user_data['waiting_for'] = 'copy_product'
await query.edit_message_text(
"✍️ *生成文案*\n\n請輸入商品名稱:\n\n"
"例如:防曬乳、保濕面膜",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回主選單", callback_data="menu_main")
]])
)
elif data == "menu_keywords":
await query.edit_message_text(
"🏷️ *熱門關鍵字*\n\n請選擇分類:",
parse_mode='Markdown',
reply_markup=self._get_category_keyboard("keywords")
)
elif data == "menu_daily":
await query.edit_message_text("📰 正在載入今日趨勢摘要...")
await self._show_daily_summary(query)
elif data == "menu_settings":
await self._show_settings(query)
# ===== 趨勢分類按鈕 =====
elif data.startswith("trend_"):
category = data[6:]
await query.edit_message_text(f"🔄 正在查詢 {category} 趨勢...")
await self._show_trend_by_category(query, category)
# ===== 關鍵字分類按鈕 =====
elif data.startswith("keywords_"):
category = data[9:]
await query.edit_message_text(f"🔄 正在查詢 {category} 熱門關鍵字...")
await self._show_keywords_by_category(query, category)
# ===== 設定按鈕 =====
elif data.startswith("settings_"):
await self._handle_settings_callback(query, data)
async def _handle_event_ignore_callback(self, query, data: str):
"""A'HITL 升級審核「🛑 忽略此事件」按鈕處理。
callback_data 格式momo:eig:<event_id>
動作:
1. 寫入 ai_insights 一筆 status='ignored' 紀錄(含 event_id 供 audit
2. 編輯原訊息:標記「已忽略 by <user> @ <ts>」
3. 失敗 best-effort不阻斷 user 體驗
ADR-012 §③:人工決策必有 audit trailai_insights 為 SOT。
Critic Critical-1 fix: user_label / ts_label 寫入 HTML 前須 escape
Critic Medium-2 fix: 空 event_id 直接拒絕,避免污染 audit 資料。
"""
from datetime import datetime as _dt
from html import escape as _html_escape
# Critic Medium-2: 空 event_id 即拒絕(避免攻擊者送 `momo:eig:` 污染 audit
parts_in = data.split(':', 2)
event_id = parts_in[2].strip() if len(parts_in) >= 3 else ''
if not event_id:
try:
await query.answer("event_id 缺失,忽略動作未生效", show_alert=False)
except Exception:
pass
logger.warning("[EA HITL] empty event_id callback rejected: %r", data)
return
user = getattr(query, 'from_user', None)
user_label_raw = (
getattr(user, 'username', None)
or getattr(user, 'first_name', None)
or str(getattr(user, 'id', '?'))
)
ts_label_raw = _dt.now().strftime('%Y-%m-%d %H:%M')
try:
from database.manager import get_session
from sqlalchemy import text
session = get_session()
try:
session.execute(
text("""
INSERT INTO ai_insights
(insight_type, content, confidence, created_by, status, metadata_json)
VALUES (:type, :content, :conf, :by, :status, :meta)
"""),
{
"type": "human_review",
"content": f"[EA HITL] 事件 {event_id}{user_label_raw} 忽略",
"conf": 1.0,
"by": f"telegram:{user_label_raw}",
"status": "ignored",
"meta": json.dumps({
"event_id": event_id,
"decided_by": user_label_raw,
"decided_at": ts_label_raw,
"decision": "ignored",
}, ensure_ascii=False),
},
)
session.commit()
finally:
session.close()
except Exception as audit_err:
logger.warning(f"[EA HITL] ai_insights audit 寫入失敗(不阻斷 UI: {audit_err}")
# Critic Critical-1: user_label / ts_label 須 HTML escape避免攻擊者
# 透過 Telegram username 注入 <a>/<pre>/破版標籤
user_label_safe = _html_escape(str(user_label_raw))
ts_label_safe = _html_escape(ts_label_raw)
try:
original = query.message.text_html if query.message and getattr(query.message, 'text_html', None) else (query.message.text if query.message else "")
footer = f"\n\n🛑 <b>已忽略</b> by {user_label_safe} @ {ts_label_safe}"
await query.edit_message_text(
(original or '事件已忽略') + footer,
parse_mode='HTML',
)
except Exception as ui_err:
logger.debug(f"[EA HITL] edit_message 失敗(不阻斷): {ui_err}")
logger.info(f"[EA HITL] event_ignore event_id={event_id} by={user_label_raw}")
async def _handle_openclaw_callback(self, query, context, data: str):
"""轉接 OpenClaw 完整菜單 callback避免長輪詢 Bot 吃掉 /menu。"""
chat_id = query.message.chat_id
reply_to = query.message.message_id
try:
if data.startswith('menu:'):
from routes import openclaw_bot_routes as openclaw
key = data[5:]
submenu = openclaw._SUBMENUS.get(key)
if not submenu:
await query.message.reply_text("⚠️ 找不到這個選單")
return
titles = {
'main': '👋 *OpenClaw* — 請選擇功能類別',
'sales': '📊 *業績查詢* — 選擇日期或直接輸入',
'products': '🏆 *商品廠商* — 選擇查詢範圍',
'goals': '🎯 *目標管理* — 查看或設定業績目標',
'analysis': '📈 *智能分析* — 選擇分析類型',
'trend': '📈 *業績趨勢* — 選擇時間範圍',
'reports': '📄 *簡報報表* — 選擇報告類型',
'market': '🌐 *市場情報* — 即時資訊',
'competitor': '📊 *競品比價日報* — 選擇分析日期',
'competitor_ppt': '📄 *競品比價簡報* — 選擇時間範圍',
'category': '🗂 *分類業績鑽取* — 點選分類深入分析',
}
await query.edit_message_text(
titles.get(key, '請選擇'),
parse_mode='Markdown',
reply_markup=self._to_inline_markup(submenu()),
)
return
if data.startswith('await:'):
from routes.openclaw_bot_routes import _AWAIT_PROMPTS
action = data[6:]
prompt = _AWAIT_PROMPTS.get(action)
if not prompt:
await query.message.reply_text("⚠️ 找不到這個輸入流程")
return
context.user_data['openclaw_waiting_for'] = action
prompt_text, _label = prompt
await query.edit_message_text(
f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
parse_mode='Markdown',
reply_markup=self._to_inline_markup([
[{'text': '✖ 取消', 'callback_data': 'menu:main'}]
]),
)
return
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')
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:
logger.error(f"OpenClaw callback 轉接失敗: {e}", exc_info=True)
await query.message.reply_text("⚠️ 功能執行失敗,請稍後再試。")
async def _show_trend_by_category(self, query, category: str):
"""顯示指定分類的趨勢"""
try:
from database.trend_models import TrendRecord
from database.manager import get_session
session = get_session()
date_from = date.today() - timedelta(days=7)
records = session.query(TrendRecord).filter(
TrendRecord.trend_date >= date_from,
TrendRecord.category == category
).order_by(
TrendRecord.popularity_score.desc()
).limit(10).all()
session.close()
if not records:
await query.edit_message_text(
f"📭 {category} 分類近 7 天沒有趨勢資料",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回選擇分類", callback_data="menu_trend"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
return
lines = [f"📊 *{category} 熱門趨勢* (近7天)\n"]
for i, r in enumerate(records, 1):
source_emoji = {'ptt': '💬', 'dcard': '📱', 'google_news': '📰', 'youtube': '🎬'}.get(r.source, '📄')
title = r.title[:25] + '...' if len(r.title) > 25 else r.title
lines.append(f"{i}. {source_emoji} {title} ({r.popularity_score})")
await query.edit_message_text(
'\n'.join(lines),
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔄 重新整理", callback_data=f"trend_{category}"),
InlineKeyboardButton("📊 其他分類", callback_data="menu_trend")
], [
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
except Exception as e:
logger.error(f"_show_trend_by_category 失敗: {e}")
await query.edit_message_text(
f"❌ 查詢失敗: {str(e)}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回", callback_data="menu_main")
]])
)
async def _show_keywords_by_category(self, query, category: str):
"""顯示指定分類的熱門關鍵字"""
try:
from database.trend_models import TrendKeyword
from database.manager import get_session
from sqlalchemy import func
session = get_session()
date_from = date.today() - timedelta(days=7)
keywords = session.query(
TrendKeyword.keyword,
func.sum(TrendKeyword.mention_count).label('total')
).filter(
TrendKeyword.trend_date >= date_from,
TrendKeyword.category == category
).group_by(TrendKeyword.keyword).order_by(
func.sum(TrendKeyword.mention_count).desc()
).limit(15).all()
session.close()
if not keywords:
await query.edit_message_text(
f"📭 {category} 分類近 7 天沒有熱門關鍵字",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回選擇分類", callback_data="menu_keywords"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
return
lines = [f"🏷️ *{category} 熱門關鍵字* (近7天)\n"]
for i, kw in enumerate(keywords, 1):
lines.append(f"{i}. {kw.keyword} ({kw.total}次)")
await query.edit_message_text(
'\n'.join(lines),
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔄 重新整理", callback_data=f"keywords_{category}"),
InlineKeyboardButton("🏷️ 其他分類", callback_data="menu_keywords")
], [
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
except Exception as e:
logger.error(f"_show_keywords_by_category 失敗: {e}")
await query.edit_message_text(
f"❌ 查詢失敗: {str(e)}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回", callback_data="menu_main")
]])
)
async def _show_daily_summary(self, query):
"""顯示每日趨勢摘要"""
try:
from database.trend_models import TrendAnalysis
from database.manager import get_session
session = get_session()
analysis = session.query(TrendAnalysis).filter(
TrendAnalysis.analysis_date == date.today(),
TrendAnalysis.analysis_type == 'daily_summary'
).first()
session.close()
if not analysis:
await query.edit_message_text(
"📭 今日尚無趨勢分析報告\n\n趨勢爬蟲每 2 小時執行一次",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔄 重新載入", callback_data="menu_daily"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
return
hot_keywords = json.loads(analysis.hot_keywords or '[]')
marketing = json.loads(analysis.marketing_suggestions or '[]')
lines = ["📰 *今日趨勢摘要*\n"]
lines.append(f"📝 *概況:*\n{analysis.summary[:200]}...\n" if len(analysis.summary) > 200 else f"📝 *概況:*\n{analysis.summary}\n")
if hot_keywords:
lines.append(f"🏷️ *熱門關鍵字:*\n{', '.join(hot_keywords[:8])}\n")
if marketing:
lines.append("💡 *行銷建議:*")
for i, m in enumerate(marketing[:3], 1):
lines.append(f"{i}. {m[:50]}...")
await query.edit_message_text(
'\n'.join(lines),
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔄 重新載入", callback_data="menu_daily"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
except Exception as e:
logger.error(f"_show_daily_summary 失敗: {e}")
await query.edit_message_text(
f"❌ 載入失敗: {str(e)}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回", callback_data="menu_main")
]])
)
async def _show_settings(self, query):
"""顯示設定選單"""
keyboard = [
[InlineKeyboardButton("🔔 開啟趨勢通知", callback_data="settings_notify_on")],
[InlineKeyboardButton("🔕 關閉趨勢通知", callback_data="settings_notify_off")],
[InlineKeyboardButton("📊 訂閱每日摘要", callback_data="settings_daily_on")],
[InlineKeyboardButton("📭 取消每日摘要", callback_data="settings_daily_off")],
[InlineKeyboardButton("🏠 主選單", callback_data="menu_main")],
]
await query.edit_message_text(
"⚙️ *設定*\n\n請選擇要調整的設定:",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup(keyboard)
)
async def _handle_settings_callback(self, query, data: str):
"""處理設定相關的回調"""
from database.trend_models import TelegramUser
from database.manager import get_session
user_id = query.from_user.id
session = get_session()
try:
# 取得或建立用戶記錄
tg_user = session.query(TelegramUser).filter(
TelegramUser.telegram_id == user_id
).first()
if not tg_user:
tg_user = TelegramUser(
telegram_id=user_id,
telegram_username=query.from_user.username,
display_name=query.from_user.first_name,
is_active=True
)
session.add(tg_user)
# 處理設定變更
if data == "settings_notify_on":
tg_user.notify_trends = True
msg = "✅ 已開啟趨勢通知"
elif data == "settings_notify_off":
tg_user.notify_trends = False
msg = "🔕 已關閉趨勢通知"
elif data == "settings_daily_on":
tg_user.notify_daily_summary = True
msg = "✅ 已訂閱每日摘要 (每天 09:00 發送)"
elif data == "settings_daily_off":
tg_user.notify_daily_summary = False
msg = "📭 已取消每日摘要訂閱"
else:
msg = "❓ 未知的設定"
session.commit()
await query.edit_message_text(
f"{msg}\n\n目前設定:\n"
f"🔔 趨勢通知: {'開啟' if tg_user.notify_trends else '關閉'}\n"
f"📰 每日摘要: {'訂閱中' if tg_user.notify_daily_summary else '未訂閱'}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("⚙️ 返回設定", callback_data="menu_settings"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
except Exception as e:
logger.error(f"_handle_settings_callback 失敗: {e}")
session.rollback()
await query.edit_message_text(f"❌ 設定失敗: {str(e)}")
finally:
session.close()
async def handle_message(self, update: Update, context):
"""處理一般訊息"""
text = update.message.text
waiting_for = context.user_data.get('waiting_for')
openclaw_waiting_for = context.user_data.get('openclaw_waiting_for')
if openclaw_waiting_for:
context.user_data['openclaw_waiting_for'] = None
await self._process_openclaw_input(update, openclaw_waiting_for, text)
return
# 處理等待輸入的狀態
if waiting_for == 'search_query':
context.user_data['waiting_for'] = None
await self._process_search(update, text)
return
if waiting_for == 'copy_product':
context.user_data['waiting_for'] = None
await self._process_copy(update, text)
return
# 簡單的自然語言處理 - 顯示主選單
if '趨勢' in text or '熱門' in text:
await update.message.reply_text(
"💡 請選擇功能:",
reply_markup=self._get_main_menu_keyboard()
)
elif '搜尋' in text or '查詢' in text:
context.user_data['waiting_for'] = 'search_query'
await update.message.reply_text(
"🔍 請輸入要搜尋的關鍵字:",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 取消", callback_data="menu_main")
]])
)
elif '文案' in text or '生成' in text:
context.user_data['waiting_for'] = 'copy_product'
await update.message.reply_text(
"✍️ 請輸入商品名稱:",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 取消", callback_data="menu_main")
]])
)
else:
await update.message.reply_chat_action(action='typing')
try:
from routes.openclaw_bot_routes import openclaw_answer
_chat_id = getattr(update.effective_chat, 'id', None)
txt, kb = openclaw_answer(text, chat_id=_chat_id)
reply_markup = None
if kb:
inline_kb = []
for row in kb:
inline_row = []
for btn in row:
inline_row.append(InlineKeyboardButton(text=btn['text'], callback_data=btn['callback_data']))
inline_kb.append(inline_row)
reply_markup = InlineKeyboardMarkup(inline_kb)
await update.message.reply_text(txt, reply_markup=reply_markup)
except Exception as e:
logger.error(f"自然對話處理失敗: {e}")
await update.message.reply_text("不好意思,我現在無法回答這個問題,請稍後再試。")
async def _process_openclaw_input(self, update: Update, action: str, text: str):
"""處理 OpenClaw 菜單進入的文字輸入流程。"""
from routes import openclaw_bot_routes as openclaw
chat_id = update.effective_chat.id
reply_to = update.message.message_id
val = text.strip().replace(',', '').replace('NT$', '').replace('$', '').strip()
if text in ('/取消', '/cancel'):
await update.message.reply_text(
"已取消",
reply_markup=self._to_inline_markup(openclaw.main_menu_keyboard()),
)
return
try:
if action.startswith('goal_'):
period_map = {
'goal_daily': 'daily',
'goal_monthly': 'monthly',
'goal_quarterly': 'quarterly',
'goal_half': 'half',
'goal_yearly': 'yearly',
}
period = period_map[action]
amount = float(val)
openclaw._GOALS[period] = amount
await update.message.reply_text(
f"✅ 目標已設定為 NT$ {amount:,.0f}",
reply_markup=self._to_inline_markup(openclaw._submenu_goals()),
)
return
if action == 'search_compare':
openclaw.handle_cmd('competitor', val, chat_id, reply_to)
return
if action == 'date_range_sales':
import re
dates = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
month_only = re.match(r'(\d{4})[/\-](\d{1,2})$', val)
if len(dates) >= 2:
start = openclaw.normalize_date(dates[0])
end = openclaw.normalize_date(dates[1])
if start == end:
openclaw.handle_cmd('sales', start, chat_id, reply_to)
else:
start_d = datetime.strptime(start.replace('/', '-'), '%Y-%m-%d').date()
end_d = datetime.strptime(end.replace('/', '-'), '%Y-%m-%d').date()
days_count = (end_d - start_d).days + 1
data = openclaw.query_trend_range(start, end)
if data:
period_label = f'{start} ~ {end}{days_count}天)'
openclaw.send_message(
chat_id,
openclaw.fmt_trend(data, period_label),
reply_to,
openclaw._submenu_sales(),
)
else:
openclaw.send_message(chat_id, f"⚠️ {start} ~ {end} 查無業績資料", reply_to)
elif len(dates) == 1:
openclaw.handle_cmd('sales', openclaw.normalize_date(dates[0]), chat_id, reply_to)
elif month_only:
openclaw.handle_cmd(
'history',
f"{month_only.group(1)}/{int(month_only.group(2)):02d}",
chat_id,
reply_to,
)
else:
await update.message.reply_text("⚠️ 格式錯誤,請重新輸入日期或日期區間。")
return
if action.startswith('date_trend_'):
openclaw.handle_cmd('trend', val.replace('-', '/'), chat_id, reply_to)
return
if action.startswith('date_'):
import re
date_val = val.replace('-', '/')
if not re.match(r'\d{4}/\d{1,2}(/\d{1,2})?$', date_val):
await update.message.reply_text("⚠️ 日期格式錯誤,請重新輸入。")
return
command_map = {
'date_sales': ('sales', date_val),
'date_top': ('top', date_val),
'date_analysis': ('strategy', date_val),
'date_ppt_daily': ('ppt', f'daily {date_val}'),
'date_ppt_monthly': ('ppt', f'monthly {date_val}'),
'date_competitor': ('ppt', f'competitor {date_val}'),
}
command = command_map.get(action)
if command:
openclaw.handle_cmd(command[0], command[1], chat_id, reply_to)
return
if action == 'promo_range':
import re
dates = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
if len(dates) >= 2:
openclaw.handle_cmd(
'promo',
f"{openclaw.normalize_date(dates[0])}-{openclaw.normalize_date(dates[1])}",
chat_id,
reply_to,
)
else:
await update.message.reply_text("⚠️ 格式錯誤例如2026/04/01-2026/04/07")
return
await update.message.reply_text("⚠️ 這個輸入流程暫時無法處理。")
except Exception as e:
logger.error(f"OpenClaw 輸入流程處理失敗: {e}", exc_info=True)
await update.message.reply_text("⚠️ 輸入處理失敗,請稍後再試。")
async def _process_search(self, update: Update, query: str):
"""處理搜尋請求"""
await update.message.reply_text(f"🔍 正在搜尋「{query}」...")
try:
from services.trend_crawler_service import get_trend_crawler_service
service = get_trend_crawler_service()
result = service.web_search_with_cache(query, search_type='trends')
if result['success']:
data = result['data']
parsed = data.get('result', {})
summary = parsed.get('summary', '無摘要')
await update.message.reply_text(
f"🔍 *搜尋結果: {query}*\n\n{summary[:500]}",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔍 繼續搜尋", callback_data="menu_search"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
else:
await update.message.reply_text(
f"❌ 搜尋失敗: {result.get('error', '未知錯誤')}",
reply_markup=self._get_main_menu_keyboard()
)
except Exception as e:
logger.error(f"_process_search 失敗: {e}")
await update.message.reply_text(
f"❌ 搜尋失敗: {str(e)}",
reply_markup=self._get_main_menu_keyboard()
)
async def _process_copy(self, update: Update, product_name: str):
"""處理文案生成請求"""
await update.message.reply_text(f"✍️ 正在為「{product_name}」生成文案...")
try:
from services.ollama_service import OllamaService
ollama = OllamaService()
prompt = f"""請為以下商品生成 3 種風格的行銷文案:
商品: {product_name}
請分別生成:
1. 標準版 (80字內)
2. 活潑版 (含表情符號80字內)
3. 限時版 (強調緊迫感80字內)
以繁體中文回覆,格式簡潔。"""
response = ollama.generate(prompt, temperature=0.7)
if response.success:
await update.message.reply_text(
f"✨ *{product_name} 行銷文案*\n\n{response.content[:1000]}",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("✍️ 再生成一次", callback_data="menu_copy"),
InlineKeyboardButton("🏠 主選單", callback_data="menu_main")
]])
)
else:
await update.message.reply_text(
f"❌ 生成失敗: {response.error}",
reply_markup=self._get_main_menu_keyboard()
)
except Exception as e:
logger.error(f"_process_copy 失敗: {e}")
await update.message.reply_text(
f"❌ 生成失敗: {str(e)}",
reply_markup=self._get_main_menu_keyboard()
)
# ========== 推播功能 ==========
async def send_message(self, chat_id: int, message: str, parse_mode: str = 'Markdown'):
"""發送訊息到指定聊天室"""
if not self.application or not self.is_running:
logger.warning("Bot 未運行,無法發送訊息")
return False
try:
await self.application.bot.send_message(
chat_id=chat_id,
text=message,
parse_mode=parse_mode
)
return True
except Exception as e:
logger.error(f"發送訊息失敗: {e}")
return False
async def broadcast_trend_alert(self, message: str, category: str = None):
"""推播趨勢警報給所有訂閱用戶"""
from database.trend_models import TelegramUser
from database.manager import get_session
session = get_session()
try:
query = session.query(TelegramUser).filter(
TelegramUser.is_active == True,
TelegramUser.notify_trends == True
)
if category:
query = query.filter(
TelegramUser.preferred_categories.like(f'%{category}%')
)
users = query.all()
sent_count = 0
for user in users:
if await self.send_message(user.telegram_id, message):
sent_count += 1
logger.info(f"趨勢警報已推播給 {sent_count}/{len(users)} 位用戶")
return sent_count
finally:
session.close()
async def send_daily_summary(self):
"""推播每日趨勢摘要給訂閱用戶"""
from database.trend_models import TelegramUser, TrendAnalysis
from database.manager import get_session
session = get_session()
try:
# 取得今日摘要
analysis = session.query(TrendAnalysis).filter(
TrendAnalysis.analysis_date == date.today(),
TrendAnalysis.analysis_type == 'daily_summary'
).first()
if not analysis:
logger.warning("每日摘要推播: 今日尚無分析報告")
return 0
# 組裝訊息
hot_keywords = json.loads(analysis.hot_keywords or '[]')
marketing = json.loads(analysis.marketing_suggestions or '[]')
message = "📰 *MOMO 每日趨勢摘要*\n\n"
message += f"📅 {date.today().strftime('%Y-%m-%d')}\n\n"
message += f"📝 *概況:*\n{analysis.summary[:300]}{'...' if len(analysis.summary) > 300 else ''}\n\n"
if hot_keywords:
message += f"🏷️ *熱門關鍵字:*\n{', '.join(hot_keywords[:10])}\n\n"
if marketing:
message += "💡 *行銷建議:*\n"
for i, m in enumerate(marketing[:3], 1):
message += f"{i}. {m[:60]}...\n"
message += "\n👉 在 Bot 輸入 /daily 查看完整報告"
# 取得訂閱用戶
users = session.query(TelegramUser).filter(
TelegramUser.is_active == True,
TelegramUser.notify_daily_summary == True
).all()
sent_count = 0
for user in users:
if await self.send_message(user.telegram_id, message):
sent_count += 1
logger.info(f"每日摘要已推播給 {sent_count}/{len(users)} 位用戶")
return sent_count
except Exception as e:
logger.error(f"send_daily_summary 失敗: {e}")
return 0
finally:
session.close()
def get_application(self):
"""取得 Application 實例 (供外部啟動使用)"""
if not TELEGRAM_AVAILABLE or not self.token:
return None
if not self.application:
self.application = Application.builder().token(self.token).build()
# 註冊處理器
self.application.add_handler(CommandHandler("start", self.cmd_start))
self.application.add_handler(CommandHandler("menu", self.cmd_menu))
self.application.add_handler(CommandHandler("help", self.cmd_help))
self.application.add_handler(CommandHandler("trend", self.cmd_trend))
self.application.add_handler(CommandHandler("search", self.cmd_search))
self.application.add_handler(CommandHandler("copy", self.cmd_copy))
self.application.add_handler(CommandHandler("keywords", self.cmd_keywords))
self.application.add_handler(CommandHandler("daily", self.cmd_daily))
self.application.add_handler(CallbackQueryHandler(self.handle_callback))
self.application.add_handler(MessageHandler(
filters.TEXT & ~filters.COMMAND,
self.handle_message
))
return self.application
# 全域實例
_bot_instance: Optional[TrendTelegramBot] = None
def get_telegram_bot() -> TrendTelegramBot:
"""取得 Telegram Bot 實例"""
global _bot_instance
if _bot_instance is None:
_bot_instance = TrendTelegramBot()
return _bot_instance
def is_telegram_available() -> bool:
"""檢查 Telegram 功能是否可用"""
return TELEGRAM_AVAILABLE and bool(os.getenv('TELEGRAM_BOT_TOKEN'))
# 別名 (供 run_telegram_bot.py 使用)
TelegramBotService = TrendTelegramBot