feat(telegram): 統一訊息格式模板(六類 + callback prefix)
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s

新增 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>
This commit is contained in:
ogt
2026-04-19 12:28:23 +08:00
parent 8d0b79cd00
commit 528a6c0468
4 changed files with 248 additions and 38 deletions

View File

@@ -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 = []

View File

@@ -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:

View File

@@ -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'
)

View File

@@ -0,0 +1,219 @@
"""
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))