""" Telegram 訊息模板庫(EwoooC 統一格式規範 v2 · HTML) 設計原則: 1. 純函數 — scheduler / telegram-bot / event_router 都能呼叫 2. 六類訊息 + 三個 HITL 變體:🚨 告警 / ⚠️ 警告 / ℹ️ 資訊 / ✅ 成功 / 📊 報告 / 💰 決策 / 🛠️ Ops 3. 使用 Telegram HTML parse_mode(相容性最好,只 escape & < >,不會有反斜線 escape 破版) 4. 三層式結構:事件資訊 / 🤖 AI 加工區 / 🔍 原始技術細節 — 明確分隔線區隔 5. callback_data 必用 momo: prefix(ADR-011) 6. 訊息 >3500 chars 自動截斷 呼叫端發送時務必使用 `parse_mode='HTML'` """ from datetime import datetime from typing import Any MAX_LEN = 3500 H_DIV = "━" * 20 # 強分隔線(節與節之間) L_DIV = "─" * 18 # 弱分隔線(AI 區內部) PROJECT_TAG = "EwoooC" # 跨專案共用 bot 識別來源(ADR-011) CB_PREFIX = "momo:" PARSE_MODE = "HTML" # 統一 parse_mode def _ts(dt: datetime | None = None) -> str: return (dt or datetime.now()).strftime("%Y-%m-%d %H:%M") def _esc(s: Any) -> str: """Escape HTML 特殊字元(Telegram HTML 只認 & < >)""" if s is None: return "" return (str(s).replace("&", "&") .replace("<", "<") .replace(">", ">")) 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:] def _header(emoji: str, category: str, title: str, module: str, time: datetime | None = None) -> str: """統一標題區:emoji + 分類 + 標題 + 時間/模組""" return ( f"{emoji} [{PROJECT_TAG} {category}] {_esc(title)}\n" f"🕐 {_ts(time)} 📦 {_esc(module)}\n" f"{H_DIV}" ) def _details_block(details: dict[str, Any] | None) -> str: """結構化明細區塊""" if not details: return "" out = [] for k, v in details.items(): out.append(f"• {_esc(k)}:{_esc(v)}") return "\n".join(out) # ===================================================================== # 🚨 告警(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: parts = [_header("🚨", "告警", title, module, time)] parts.append(f"\n❌ 狀態:{_esc(status)}") parts.append(f"📍 影響:{_esc(impact)}") parts.append(f"💬 {_esc(summary)}") if actions: parts.append(f"\n🔧 建議行動") for a in actions: parts.append(f" • {_esc(a)}") if trace: parts.append(f"\n{H_DIV}") parts.append(f"🔍 原始技術細節(末段)") parts.append(f"
{_esc(_tail(trace))}
") return _clip("\n".join(parts)) # ===================================================================== # ⚠️ 警告(P2) # ===================================================================== def warning( title: str, module: str, summary: str, details: dict[str, Any] | None = None, time: datetime | None = None, ) -> str: parts = [_header("⚠️", "警告", title, module, time)] parts.append(f"\n📌 {_esc(summary)}") db = _details_block(details) if db: parts.append("") parts.append(db) return _clip("\n".join(parts)) # ===================================================================== # ℹ️ 資訊 # ===================================================================== def info(title: str, module: str, content: str, time: datetime | None = None) -> str: return _clip( f"{_header('ℹ️', '資訊', title, module, time)}\n" f"\n{_esc(content)}" ) # ===================================================================== # ✅ 成功 # ===================================================================== def success( title: str, module: str, stats: str | None = None, duration: str | None = None, detail: str | None = None, time: datetime | None = None, ) -> str: parts = [_header("✅", "完成", title, module, time)] if stats: parts.append(f"\n📊 {_esc(stats)}") if duration: parts.append(f"⏱️ 耗時:{_esc(duration)}") if detail: parts.append(f"\n{_esc(detail)}") return _clip("\n".join(parts)) # ===================================================================== # 📊 報告(日報 / 週報 / 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 保留原始 Markdown(Gemini 輸出),但會把 `*` `_` `[]` 轉成 HTML 等價。 - **粗體** → 粗體 - *斜體* → 斜體 - 其他純文本 escape HTML """ # 簡化:只做最基本的 & < > escape,讓 Gemini 原生文字可讀即可 content_html = _esc(content_md) parts = [ f"📊 [{PROJECT_TAG} {_esc(report_type)}] {_esc(title)}", f"🕐 {_ts(time)} 🗓️ {_esc(period)}", H_DIV, "", content_html, ] if citations: parts += ["", H_DIV, f"📚 {_esc(citations)}"] return _clip("\n".join(parts)) # ===================================================================== # 🤖 Triaged Alert — L1/L2 AI 加工訊息(ADR-012 §④ 三層式) # ===================================================================== def triaged_alert( base_event: dict, tier_label: str, # "L1 · Hermes" / "L2 · NemoTron" ai_summary: str, # Hermes 翻譯 ai_cause: str | None = None, # 可能根因 ai_actions: list[str] | None = None, # 建議動作 ai_executed: list[str] | None = None, # L2 已執行的 action(如 retry_task → scheduled) ) -> str: """ 三層式訊息: [事件資訊] → [🤖 AI 加工區] → [🔍 原始技術細節] base_event 欄位:title, module, status, impact, summary, details, trace """ sev = base_event.get("severity", "warning") emoji = "🚨" if sev == "alert" else "⚠️" category = "告警" if sev == "alert" else "警告" parts = [_header(emoji, category, base_event.get("title", ""), base_event.get("module", "unknown"))] # Section 1: 事件資訊 if base_event.get("status"): parts.append(f"\n❌ 狀態:{_esc(base_event['status'])}") if base_event.get("impact"): parts.append(f"📍 影響:{_esc(base_event['impact'])}") if base_event.get("summary"): parts.append(f"💬 {_esc(base_event['summary'])}") db = _details_block(base_event.get("details")) if db: parts.append("") parts.append(db) # Section 2: AI 加工區(明顯分隔) parts.append(f"\n{H_DIV}") parts.append(f"🤖 AI 分析({_esc(tier_label)})") parts.append("") parts.append(f"📝 技術根因翻譯") parts.append(_esc(ai_summary)) if ai_cause: parts.append("") parts.append(f"🔎 可能原因") parts.append(_esc(ai_cause)) if ai_actions: parts.append("") parts.append(f"🔧 建議動作") for i, a in enumerate(ai_actions[:5], 1): parts.append(f" {i}. {_esc(a)}") if ai_executed: parts.append("") parts.append(f"⚡ AI 已自動執行") for a in ai_executed: parts.append(f" • {_esc(a)}") # Section 3: 原始技術細節(可選) trace = base_event.get("trace") if trace: parts.append(f"\n{H_DIV}") parts.append(f"🔍 原始技術細節(末段)") parts.append(f"
{_esc(_tail(trace))}
") return _clip("\n".join(parts)) # ===================================================================== # 💰 降價決策請求(P2/P3) # ===================================================================== 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]: drop_pct = (current_price - suggested_price) / current_price * 100 if current_price > 0 else 0 text = "\n".join([ f"💰 [{PROJECT_TAG} 決策請求] 降價建議", f"🕐 {_ts(time)} 📦 OpenClaw Strategist", H_DIV, "", f"🏷️ 商品:{_esc(product_name)}", f"📦 貨號{_esc(product_sku or 'N/A')}", f"💵 現價:${current_price:,.0f}", f"📉 建議降至:${suggested_price:,.0f}(↓{drop_pct:.1f}%)", "", f"🤖 AI 理由", _esc(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 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 = [ "", H_DIV, f"{emoji} {label}", f"👤 操作人:{_esc(operator)}", f"🕐 {_ts()}", ] if note: footer.append(f"📝 {_esc(note)}") return _clip(original_text + "\n".join(footer)) # ===================================================================== # 🛠️ L3 Ops Action Request(Phase 4 HITL) # ===================================================================== def ops_action_request( task_name: str, title: str, reason: str, context: dict | None = None, time: datetime | None = None, ) -> tuple[str, dict]: parts = [ f"🛠️ [{PROJECT_TAG} 運維決策] {_esc(title)}", f"🕐 {_ts(time)} 📦 {_esc(task_name)}", H_DIV, "", f"💬 {_esc(reason)}", ] if context: parts.append("") parts.append(_details_block(context)) parts += ["", "👉 請選擇動作"] keyboard = { "inline_keyboard": [ [ {"text": "⏸️ 暫停 1h", "callback_data": f"{CB_PREFIX}ops:pause1h:{task_name}"}, {"text": "⏸️ 暫停 6h", "callback_data": f"{CB_PREFIX}ops:pause6h:{task_name}"}, ], [ {"text": "⚡ 立即重試", "callback_data": f"{CB_PREFIX}ops:retry:{task_name}"}, {"text": "▶️ 解除暫停", "callback_data": f"{CB_PREFIX}ops:resume:{task_name}"}, ], ] } return _clip("\n".join(parts)), keyboard def ops_action_result( original_text: str, action: str, operator: str, result: dict, ) -> str: emoji_map = {"pause1h": "⏸️", "pause6h": "⏸️", "retry": "⚡", "resume": "▶️"} label_map = {"pause1h": "已暫停 1 小時", "pause6h": "已暫停 6 小時", "retry": "已立即重試", "resume": "已解除暫停"} emoji = emoji_map.get(action, "🛠️") label = label_map.get(action, action) status = result.get("status", "unknown") footer = [ "", H_DIV, f"{emoji} {label}(狀態:{_esc(status)})", f"👤 操作人:{_esc(operator)}", f"🕐 {_ts()}", ] if status == "rejected": footer.append(f"⚠️ 拒絕原因:{_esc(result.get('reason', ''))}") elif status == "deferred": footer.append(f"ℹ️ {_esc(result.get('note', ''))}") return _clip(original_text + "\n".join(footer))