From 528a6c0468a68ecd53252c75f46ae40757a3b1c1 Mon Sep 17 00:00:00 2001 From: ogt Date: Sun, 19 Apr 2026 12:28:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(telegram):=20=E7=B5=B1=E4=B8=80=E8=A8=8A?= =?UTF-8?q?=E6=81=AF=E6=A0=BC=E5=BC=8F=E6=A8=A1=E6=9D=BF=EF=BC=88=E5=85=AD?= =?UTF-8?q?=E9=A1=9E=20+=20callback=20prefix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 services/telegram_templates.py: - alert() 🚨 告警 / warning() ⚠️ 警告 / info() ℹ️ 資訊 - success() ✅ 成功 / report() 📊 報告 / price_decision() 💰 決策 - decision_result() 回執(edit_message 用) - 全訊息標 [EwoooC] 前綴(跨專案共用 bot 識別來源,見 ADR-011) - _escape_md() 處理 user input,避免 Markdown 破版 - _tail() 取 trace 末段,避開曠日 stack trace 接入點改用模板(P2/P3): - routes/bot_api_routes.py price_decision_notify - services/openclaw_strategist_service.py _send_price_decision_requests - services/telegram_bot_service.py _handle_price_approve/reject callback_data 改用 momo: prefix(舊 pa:/pr: 向下相容) 尚未接入(待下次迭代): - scheduler.py 各 task 錯誤通知 - _notify_telegram_group() 週報推播 Co-Authored-By: Claude Sonnet 4.6 --- routes/bot_api_routes.py | 26 +-- services/openclaw_strategist_service.py | 20 +-- services/telegram_bot_service.py | 21 ++- services/telegram_templates.py | 219 ++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 services/telegram_templates.py diff --git a/routes/bot_api_routes.py b/routes/bot_api_routes.py index 380ed01..76a0ebc 100644 --- a/routes/bot_api_routes.py +++ b/routes/bot_api_routes.py @@ -752,25 +752,17 @@ def price_decision_notify(): if not token: return jsonify({'success': False, 'error': 'TELEGRAM_BOT_TOKEN not configured'}), 500 - drop_pct = (current_price - suggested_price) / current_price * 100 if current_price > 0 else 0 - message = ( - f"💰 *降價決策請求*\n\n" - f"🏷️ 商品:{product_name}\n" - f"📦 貨號:`{product_sku}`\n" - f"💵 現價:${current_price:,.0f}\n" - f"📉 建議降至:${suggested_price:,.0f}(↓{drop_pct:.1f}%)\n\n" - f"🤖 *AI 理由:*\n{data['reason']}" + from services.telegram_templates import price_decision + message, keyboard = price_decision( + product_name=product_name, + product_sku=product_sku, + current_price=current_price, + suggested_price=suggested_price, + reason=data['reason'], + insight_id=insight_id, + report_url=report_url or None, ) - keyboard = {"inline_keyboard": [ - [ - {"text": "✅ 批准降價", "callback_data": f"pa:{insight_id}"}, - {"text": "❌ 拒絕", "callback_data": f"pr:{insight_id}"} - ] - ]} - if report_url: - keyboard["inline_keyboard"].append([{"text": "🔗 查看報表", "url": report_url}]) - db = DatabaseManager() sent_count = 0 errors = [] diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index de37cc0..dd4f227 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -306,19 +306,15 @@ def _send_price_decision_requests(recs: list, period_str: str, source_insight_id sys_log.warning(f"[OCStrategist] store_insight 失敗,略過 {rec['product_name']}") continue - drop_pct = (rec["current_price"] - rec["suggested_price"]) / rec["current_price"] * 100 - msg = ( - f"💰 *降價決策請求*\n\n" - f"🏷️ 商品:{rec['product_name']}\n" - f"📦 貨號:`{rec['product_sku'] or 'N/A'}`\n" - f"💵 現價:${rec['current_price']:,.0f}\n" - f"📉 建議降至:${rec['suggested_price']:,.0f}(↓{drop_pct:.1f}%)\n\n" - f"🤖 *AI 理由:*\n{rec['reason']}" + from services.telegram_templates import price_decision + msg, keyboard = price_decision( + product_name=rec["product_name"], + product_sku=rec["product_sku"], + current_price=rec["current_price"], + suggested_price=rec["suggested_price"], + reason=rec["reason"], + insight_id=rec_insight_id, ) - keyboard = {"inline_keyboard": [[ - {"text": "✅ 批准降價", "callback_data": f"pa:{rec_insight_id}"}, - {"text": "❌ 拒絕", "callback_data": f"pr:{rec_insight_id}"}, - ]]} for chat_id in admin_ids: try: diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index afa011e..71e5b78 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -461,12 +461,12 @@ class TrendTelegramBot: elif data.startswith("settings_"): await self._handle_settings_callback(query, data) - # ===== 降價決策按鈕 ===== - elif data.startswith("pa:"): - await self._handle_price_approve(query, data[3:]) + # ===== 降價決策按鈕(支援 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("pr:"): - await self._handle_price_reject(query, data[3:]) + elif data.startswith("momo:pr:") or data.startswith("pr:"): + await self._handle_price_reject(query, data.split(":")[-1]) async def _handle_price_approve(self, query, insight_id_str: str): """批准降價:寫 KM feedback + 移除按鈕""" @@ -494,9 +494,9 @@ class TrendTelegramBot: } ) - original = query.message.text or "" + from services.telegram_templates import decision_result await query.edit_message_text( - f"{original}\n\n✅ *已批准降價*\n操作人:{operator}", + decision_result(query.message.text or "", "approve", operator), parse_mode='Markdown' ) @@ -526,9 +526,12 @@ class TrendTelegramBot: } ) - original = query.message.text or "" + from services.telegram_templates import decision_result await query.edit_message_text( - f"{original}\n\n❌ *已拒絕降價*\n操作人:{operator}\n📚 已記錄為保守策略訓練資料", + decision_result( + query.message.text or "", "reject", operator, + note="已記錄為保守策略訓練資料" + ), parse_mode='Markdown' ) diff --git a/services/telegram_templates.py b/services/telegram_templates.py new file mode 100644 index 0000000..1a9a3cc --- /dev/null +++ b/services/telegram_templates.py @@ -0,0 +1,219 @@ +""" +Telegram 訊息模板庫(EwoooC 統一格式規範) + +設計原則: +1. 純函數(pure function)— 回傳字串/dict,不依賴 bot instance,scheduler 和 telegram-bot 都能用 +2. 六類訊息各自模板:🚨告警 / ⚠️警告 / ℹ️資訊 / ✅成功 / 📊報告 / 💰決策 +3. 使用者輸入經 _escape_md() 處理,避免 Markdown 特殊字元破版 +4. 訊息過長(>3500 chars)自動截斷並加 "…(已截斷)" +5. callback_data 必用專案 prefix(momo:xxx),避免共用 bot 與 AWOOOI 撞車(ADR-011) +""" + +from datetime import datetime +from typing import Any + +MAX_LEN = 3500 +DIV = "━" * 20 +PROJECT_TAG = "EwoooC" # 顯示於每則訊息,跨專案共用 bot 時識別來源(ADR-011) +CB_PREFIX = "momo:" + + +def _ts(dt: datetime | None = None) -> str: + """台北時區格式化""" + return (dt or datetime.now()).strftime("%Y-%m-%d %H:%M") + + +def _escape_md(s: Any) -> str: + """Escape Telegram 舊版 Markdown 特殊字元(避免 user input 破版)""" + s = str(s) if s is not None else "" + for ch in ("\\", "`", "*", "_", "["): + s = s.replace(ch, "\\" + ch) + return s + + +def _clip(text: str) -> str: + if len(text) <= MAX_LEN: + return text + return text[: MAX_LEN - 20] + "\n…(已截斷)" + + +def _tail(text: str, limit: int = 400) -> str: + """取末段 — stack trace 通常末端才是根因""" + if len(text) <= limit: + return text + return "…\n" + text[-limit:] + + +# ===================================================================== +# 🚨 告警(P0/P1)— 致命錯誤,需立即處理 +# ===================================================================== +def alert( + title: str, + module: str, + status: str, + impact: str, + summary: str, + actions: list[str] | None = None, + trace: str | None = None, + time: datetime | None = None, +) -> str: + out = [ + f"🚨 *[{PROJECT_TAG} 告警] {_escape_md(title)}*", + f"🕐 {_ts(time)} 📦 {_escape_md(module)}", + DIV, + f"❌ 狀態:{_escape_md(status)}", + f"📍 影響:{_escape_md(impact)}", + f"💬 {_escape_md(summary)}", + ] + if actions: + out += ["", "🔧 *建議行動:*"] + out += [f"• {_escape_md(a)}" for a in actions] + if trace: + # Trace 不 escape(在 code block 裡),只截尾部 + out += ["", "🔍 詳細錯誤(末段):", f"```\n{_tail(trace)}\n```"] + return _clip("\n".join(out)) + + +# ===================================================================== +# ⚠️ 警告(P2)— 異常但系統繼續運作 +# ===================================================================== +def warning( + title: str, + module: str, + summary: str, + details: dict[str, Any] | None = None, + time: datetime | None = None, +) -> str: + out = [ + f"⚠️ *[{PROJECT_TAG} 警告] {_escape_md(title)}*", + f"🕐 {_ts(time)} 📦 {_escape_md(module)}", + "", + f"📌 {_escape_md(summary)}", + ] + if details: + out.append("") + for k, v in details.items(): + out.append(f"• *{_escape_md(k)}*:{_escape_md(v)}") + return _clip("\n".join(out)) + + +# ===================================================================== +# ℹ️ 資訊(狀態通報) +# ===================================================================== +def info(title: str, module: str, content: str, time: datetime | None = None) -> str: + return _clip( + f"ℹ️ *[{PROJECT_TAG} 資訊] {_escape_md(title)}*\n" + f"🕐 {_ts(time)} 📦 {_escape_md(module)}\n\n" + f"{_escape_md(content)}" + ) + + +# ===================================================================== +# ✅ 成功(任務完成、部署成功) +# ===================================================================== +def success( + title: str, + module: str, + stats: str | None = None, + duration: str | None = None, + detail: str | None = None, + time: datetime | None = None, +) -> str: + out = [ + f"✅ *[{PROJECT_TAG} 完成] {_escape_md(title)}*", + f"🕐 {_ts(time)} 📦 {_escape_md(module)}", + ] + if stats: + out.append(f"📊 {_escape_md(stats)}") + if duration: + out.append(f"⏱️ 耗時:{_escape_md(duration)}") + if detail: + out += ["", _escape_md(detail)] + return _clip("\n".join(out)) + + +# ===================================================================== +# 📊 報告(日報 / 週報 / Meta-Analysis) +# ===================================================================== +def report( + title: str, + report_type: str, + period: str, + content_md: str, + citations: str | None = None, + time: datetime | None = None, +) -> str: + """ + content_md / citations 是已組好的 Markdown(例如 Gemini 原文),不再 escape。 + 呼叫端需自行確保內容乾淨。 + """ + out = [ + f"📊 *[{PROJECT_TAG} {_escape_md(report_type)}] {_escape_md(title)}*", + f"🕐 {_ts(time)} 🗓️ {_escape_md(period)}", + "", + content_md, + ] + if citations: + out += ["", DIV, f"📚 {citations}"] + return _clip("\n".join(out)) + + +# ===================================================================== +# 💰 決策請求(降價/批准)— 含 inline keyboard +# ===================================================================== +def price_decision( + product_name: str, + product_sku: str, + current_price: float, + suggested_price: float, + reason: str, + insight_id: int, + report_url: str | None = None, + time: datetime | None = None, +) -> tuple[str, dict]: + """回傳 (text, inline_keyboard_dict)""" + drop_pct = (current_price - suggested_price) / current_price * 100 if current_price > 0 else 0 + text = ( + f"💰 *[{PROJECT_TAG} 決策請求] 降價建議*\n" + f"🕐 {_ts(time)} 📦 OpenClaw Strategist\n" + f"{DIV}\n" + f"🏷️ 商品:{_escape_md(product_name)}\n" + f"📦 貨號:`{_escape_md(product_sku or 'N/A')}`\n" + f"💵 現價:${current_price:,.0f}\n" + f"📉 建議降至:${suggested_price:,.0f}(↓{drop_pct:.1f}%)\n\n" + f"🤖 *AI 理由:*\n{_escape_md(reason)}" + ) + keyboard = { + "inline_keyboard": [ + [ + {"text": "✅ 批准降價", "callback_data": f"{CB_PREFIX}pa:{insight_id}"}, + {"text": "❌ 拒絕", "callback_data": f"{CB_PREFIX}pr:{insight_id}"}, + ] + ] + } + if report_url: + keyboard["inline_keyboard"].append([{"text": "🔗 查看報表", "url": report_url}]) + return _clip(text), keyboard + + +# ===================================================================== +# 🛠️ 決策結果回執(管理員按下按鈕後,訊息會被 edit 成這個) +# ===================================================================== +def decision_result( + original_text: str, + decision: str, # "approve" or "reject" + operator: str, + note: str | None = None, +) -> str: + emoji = "✅" if decision == "approve" else "❌" + label = "已批准降價" if decision == "approve" else "已拒絕降價" + footer = [ + "", + DIV, + f"{emoji} *{label}*", + f"👤 操作人:{_escape_md(operator)}", + f"🕐 {_ts()}", + ] + if note: + footer.append(f"📝 {_escape_md(note)}") + return _clip(original_text + "\n".join(footer))