""" 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::)===== 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:: 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("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