Files
ewoooc/services/telegram_templates.py
ogt bda4edd23b
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
feat(ai-ops): ADR-012 Phase 2/3/4 完整實作
Phase 2 — Hermes L1 Observer 真實接入:
- services/event_router.py::_hermes_observe() 呼叫 hermes3:latest
  @192.168.0.111:11434/api/generate,做 stack trace 翻譯
- 輸出 JSON {summary, probable_cause, actions},容錯 markdown fence
- scheduler.py run_auto_import_task / run_momo_task 兩個 outer
  except 改走 event_router.dispatch(),帶完整 trace

Phase 3 — NemoTron L2 Investigator 規則式實作:
- event_router._L2_RULES: event_type → [(action, params)] 規則表
  • db_connection_error → query_km + retry_task(60s backoff)
  • crawler_timeout    → silence_alert(30min) + retry_task(300s)
  • nim_quota_exhausted → silence_alert(720min)
  • embedding_failure   → silence_alert(10min)
- agent_actions.retry_task 真實實作: threading.Timer + exponential
  backoff (60→120→240s) + _retry_state 追蹤 + ALLOWED_RETRY_TASKS
  白名單 + 非 scheduler 容器回 'deferred'

Phase 4 — L3 HITL Ops 擴充:
- agent_actions: pause_task / resume_task / force_retry_now / is_task_paused
- OPS_ACTIONS 白名單與 SAFE_ACTIONS 嚴格分離(L2 不可呼叫 L3)
- telegram_templates.ops_action_request(): 4 按鈕 inline keyboard
  (暫停1h / 暫停6h / 立即重試 / 解除暫停)
- telegram_bot_service._handle_ops_callback(): 接 momo:ops:<action>:<task>
- scheduler.py run_momo_task + run_auto_import_task 開頭加
  is_task_paused() 檢查(Phase 4 暫停機制生效)

安全邊界(ADR-012 §①):
- L1 Hermes 只讀 → 失敗降 L0 + 🟡 標記
- L2 NemoTron 只碰 ai_insights + 發 Telegram + SAFE_ACTIONS
- L3 OpenClaw 任意動作必經 HITL inline keyboard 批准
- 不做容器重啟按鈕(需 docker socket,風險過高)

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

290 lines
9.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 訊息模板庫EwoooC 統一格式規範)
設計原則:
1. 純函數pure function— 回傳字串/dict不依賴 bot instancescheduler 和 telegram-bot 都能用
2. 六類訊息各自模板:🚨告警 / ⚠️警告 / ℹ️資訊 / ✅成功 / 📊報告 / 💰決策
3. 使用者輸入經 _escape_md() 處理,避免 Markdown 特殊字元破版
4. 訊息過長(>3500 chars自動截斷並加 "…(已截斷)"
5. callback_data 必用專案 prefixmomo: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 成這個)
# =====================================================================
# =====================================================================
# 🛠️ L3 Ops Action Request — 附 pause/force_retry 按鈕的運維決策訊息
# =====================================================================
def ops_action_request(
task_name: str,
title: str,
reason: str,
context: dict | None = None,
time: datetime | None = None,
) -> tuple[str, dict]:
"""
L3 HITL 運維決策訊息。NemoTron 多次重試失敗 / 告警未消 → 送此訊息請管理員裁決。
回傳 (text, inline_keyboard) — callback_data 用 momo:ops:<action>:<task> 格式
"""
out = [
f"🛠️ *[{PROJECT_TAG} 運維決策] {_escape_md(title)}*",
f"🕐 {_ts(time)} 📦 {_escape_md(task_name)}",
DIV,
f"💬 {_escape_md(reason)}",
]
if context:
out.append("")
for k, v in context.items():
out.append(f"• *{_escape_md(k)}*{_escape_md(v)}")
out += ["", "👉 *請選擇動作:*"]
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(out)), keyboard
def ops_action_result(
original_text: str,
action: str, # pause1h / pause6h / retry / resume
operator: str,
result: dict,
) -> str:
"""Ops callback 執行完edit 原訊息顯示結果"""
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 = [
"",
DIV,
f"{emoji} *{label}*(狀態:{_escape_md(status)}",
f"👤 操作人:{_escape_md(operator)}",
f"🕐 {_ts()}",
]
if status == "rejected":
footer.append(f"⚠️ 拒絕原因:{_escape_md(result.get('reason', ''))}")
elif status == "deferred":
footer.append(f" {_escape_md(result.get('note', ''))}")
return _clip(original_text + "\n".join(footer))
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))