feat(telegram): 統一訊息格式模板(六類 + callback prefix)
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s
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:
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
219
services/telegram_templates.py
Normal file
219
services/telegram_templates.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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 成這個)
|
||||
# =====================================================================
|
||||
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))
|
||||
Reference in New Issue
Block a user