All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
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>
290 lines
9.9 KiB
Python
290 lines
9.9 KiB
Python
"""
|
||
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 成這個)
|
||
# =====================================================================
|
||
# =====================================================================
|
||
# 🛠️ 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))
|