Some checks failed
CD Pipeline / deploy (push) Failing after 55s
- telegram_bot_service: 新增 /menu 指令處理器,映射到 cmd_start - openclaw_bot_routes: 優化「今日業績資料尚未匯入」訊息邏輯 - 區分「資料載入異常」vs「確實未匯入」 - 避免在已有今日資料時仍顯示未匯入訊息 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1099 lines
41 KiB
Python
1099 lines
41 KiB
Python
"""
|
||
Telegram Bot 服務
|
||
|
||
提供趨勢資料的 Telegram 互動功能
|
||
|
||
注意: 需要安裝 python-telegram-bot 套件
|
||
pip install python-telegram-bot>=20.0
|
||
|
||
功能:
|
||
- 主選單按鈕介面
|
||
- 分類選擇按鈕
|
||
- 趨勢查詢、AI 搜尋、文案生成
|
||
- 每日趨勢摘要推播
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import asyncio
|
||
import logging
|
||
from typing import Optional
|
||
from datetime import date, timedelta
|
||
|
||
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 服務"""
|
||
|
||
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
|
||
|
||
try:
|
||
self.application = Application.builder().token(self.token).build()
|
||
|
||
# 註冊指令處理器
|
||
self.application.add_handler(CommandHandler("start", self.cmd_start))
|
||
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)
|
||
|
||
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
|
||
await query.answer()
|
||
data = query.data
|
||
|
||
# ===== 主選單按鈕 =====
|
||
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)
|
||
|
||
# ===== 降價決策按鈕(支援 momo:pa:xxx 新格式 + pa:xxx 舊格式向下相容)=====
|
||
elif data.startswith("momo:pa:") or data.startswith("pa:"):
|
||
await self._handle_price_approve(query, data.split(":")[-1])
|
||
|
||
elif data.startswith("momo:pr:") or data.startswith("pr:"):
|
||
await self._handle_price_reject(query, data.split(":")[-1])
|
||
|
||
# ===== L3 運維決策按鈕(momo:ops:<action>:<task_name>)=====
|
||
elif data.startswith("momo:ops:"):
|
||
await self._handle_ops_callback(query, data)
|
||
|
||
async def _handle_price_approve(self, query, insight_id_str: str):
|
||
"""批准降價:寫 KM feedback + 移除按鈕"""
|
||
from services.openclaw_learning_service import store_insight
|
||
from datetime import date as date_cls
|
||
|
||
try:
|
||
insight_id = int(insight_id_str)
|
||
except ValueError:
|
||
await query.answer("無效的決策 ID", show_alert=True)
|
||
return
|
||
|
||
user = query.from_user
|
||
operator = user.full_name or f"id_{user.id}"
|
||
|
||
store_insight(
|
||
insight_type="price_decision_feedback",
|
||
content=f"管理員批准降價建議(source_insight_id={insight_id})",
|
||
period=date_cls.today().isoformat(),
|
||
metadata={
|
||
"decision": "approve",
|
||
"source_insight_id": insight_id,
|
||
"operator": operator,
|
||
"operator_tg_id": user.id,
|
||
}
|
||
)
|
||
|
||
from services.telegram_templates import decision_result
|
||
await query.edit_message_text(
|
||
decision_result(query.message.text or "", "approve", operator),
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
async def _handle_price_reject(self, query, insight_id_str: str):
|
||
"""拒絕降價:寫 KM 訓練保守策略 + 移除按鈕"""
|
||
from services.openclaw_learning_service import store_insight
|
||
from datetime import date as date_cls
|
||
|
||
try:
|
||
insight_id = int(insight_id_str)
|
||
except ValueError:
|
||
await query.answer("無效的決策 ID", show_alert=True)
|
||
return
|
||
|
||
user = query.from_user
|
||
operator = user.full_name or f"id_{user.id}"
|
||
|
||
store_insight(
|
||
insight_type="price_decision_feedback",
|
||
content=f"管理員拒絕降價建議(source_insight_id={insight_id}),訓練保守策略",
|
||
period=date_cls.today().isoformat(),
|
||
metadata={
|
||
"decision": "reject",
|
||
"source_insight_id": insight_id,
|
||
"operator": operator,
|
||
"operator_tg_id": user.id,
|
||
}
|
||
)
|
||
|
||
from services.telegram_templates import decision_result
|
||
await query.edit_message_text(
|
||
decision_result(
|
||
query.message.text or "", "reject", operator,
|
||
note="已記錄為保守策略訓練資料"
|
||
),
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
async def _handle_ops_callback(self, query, data: str):
|
||
"""
|
||
L3 運維決策 callback(ADR-012 Phase 4)
|
||
callback_data 格式:momo:ops:<action>:<task_name>
|
||
actions: pause1h / pause6h / retry / resume
|
||
"""
|
||
from services.agent_actions import OPS_ACTIONS
|
||
from services.telegram_templates import ops_action_result
|
||
|
||
try:
|
||
_, _, action, task_name = data.split(":", 3)
|
||
except ValueError:
|
||
await query.answer("無效的 ops callback 格式", show_alert=True)
|
||
return
|
||
|
||
user = query.from_user
|
||
operator = user.full_name or f"id_{user.id}"
|
||
|
||
# action → OPS_ACTIONS 呼叫對應
|
||
action_map = {
|
||
"pause1h": ("pause_task", {"task_name": task_name, "duration_min": 60}),
|
||
"pause6h": ("pause_task", {"task_name": task_name, "duration_min": 360}),
|
||
"retry": ("force_retry_now", {"task_name": task_name}),
|
||
"resume": ("resume_task", {"task_name": task_name}),
|
||
}
|
||
mapped = action_map.get(action)
|
||
if mapped is None:
|
||
await query.answer(f"未知 ops action: {action}", show_alert=True)
|
||
return
|
||
|
||
fn_name, params = mapped
|
||
fn = OPS_ACTIONS.get(fn_name)
|
||
if fn is None:
|
||
await query.answer(f"OPS_ACTIONS 未定義 {fn_name}", show_alert=True)
|
||
return
|
||
|
||
params["operator"] = operator
|
||
try:
|
||
result = fn(**params)
|
||
except Exception as e:
|
||
result = {"status": "error", "error": str(e)[:200]}
|
||
logger.error(f"[TelegramBot] ops {action} 例外:{e}")
|
||
|
||
await query.edit_message_text(
|
||
ops_action_result(query.message.text or "", action, operator, result),
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
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')
|
||
|
||
# 處理等待輸入的狀態
|
||
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_text(
|
||
"👋 請選擇要執行的功能:",
|
||
reply_markup=self._get_main_menu_keyboard()
|
||
)
|
||
|
||
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("help", self.cmd_help))
|
||
self.application.add_handler(CommandHandler("menu", self.cmd_start)) # /menu 指令映射到 cmd_start
|
||
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
|