Files
ewoooc/services/telegram_bot_service.py
ogt 1fd1622007
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s
feat(telegram): 全面切換 HTML parse_mode + 三層式視覺分隔
起因:Markdown 舊版 parse_mode 導致 \[Demo] / task\_name 反斜線外漏,
且三層結構(事件資訊 / AI 加工區 / 原始技術細節)分隔線不夠明顯。

切換 HTML parse_mode(只需 escape & < >,不會有反斜線副作用):
- telegram_templates.py 全模板重寫為 HTML
  * <b>粗體</b> / <code>module</code> / <pre>trace</pre>
  * H_DIV (━×20) 節間強分隔 / L_DIV (─×18) 節內弱分隔
  * 新增 triaged_alert() 實作 ADR-012 §④ 三層式結構
    [事件資訊] → ━━━ → [🤖 AI 分析] → ━━━ → [🔍 原始技術細節]

event_router.py:
- _hermes_observe_parsed() 回結構化 dict {summary, cause, actions}
  取代舊的字串版本
- _render_l1/l2_with_fallback 改用 tpl.triaged_alert() 統一格式
- _send() parse_mode 改 HTML

Call sites 同步改 HTML:
- routes/bot_api_routes.py price_decision_notify
- services/openclaw_strategist_service.py 兩個發送處
- services/telegram_bot_service.py 三個 edit_message_text
  (_handle_price_approve / _handle_price_reject / _handle_ops_callback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 13:54:44 +08:00

1098 lines
41 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
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 運維決策 callbackADR-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("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