Files
awoooi/services/telegram_templates.py
ogt 528a6c0468 feat(telegram): 統一訊息格式模板(六類 + callback prefix)
新增 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 <noreply@anthropic.com>
2026-04-19 12:28:23 +08:00

220 lines
7.3 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 成這個)
# =====================================================================
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))