"""
services/telegram_templates.py
Telegram 訊息模板系統 v2
═══ 訊息分類 ═══════════════════════════════════════════════
🚨 告警類 — price_alert_msg / threat_alert_msg / system_alert_msg
📊 報告類 — daily_report_msg / weekly_report_msg / monthly_report_msg
💰 決策類 — price_decision / batch_decision_msg
🤖 系統類 — deploy_msg / heal_msg / meta_analysis_msg
💡 洞察類 — triaged_alert / insight_summary_msg
═══════════════════════════════════════════════════════════
規範:
1. 所有模板使用 HTML parse_mode
2. 一律繁體中文,Agent 名稱保留英文(Hermes/NemoTron/OpenClaw/EA)
3. 每則訊息須含:標題行 / 核心數據 / 建議行動(三段式)
4. 圖文訊息使用 send_photo_with_caption(附說明文字)
"""
import io
import json
import logging
import os
from html import escape
from datetime import datetime
from typing import Any, Dict, List, Optional
sys_log = logging.getLogger("TelegramTpl")
TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
TELEGRAM_CHAT_IDS_ENV = "TELEGRAM_CHAT_IDS"
# ══════════════════════════════════════════════════════════════════════════════
# 基礎工具
# ══════════════════════════════════════════════════════════════════════════════
def _get_bot_token() -> Optional[str]:
from dotenv import load_dotenv
load_dotenv()
return os.getenv(TELEGRAM_BOT_TOKEN_ENV)
def _get_chat_ids() -> list:
token = _get_bot_token()
if not token:
sys_log.warning("[TelegramTpl] %s 未設定,跳過 Telegram 通知", TELEGRAM_BOT_TOKEN_ENV)
return []
raw = os.getenv(TELEGRAM_CHAT_IDS_ENV, "[]")
try:
return json.loads(raw)
except json.JSONDecodeError:
sys_log.warning("[TelegramTpl] %s 格式錯誤,應為 JSON 陣列", TELEGRAM_CHAT_IDS_ENV)
return []
def _send_telegram_raw(text: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None,
parse_mode: Optional[str] = "HTML") -> bool:
"""發送純文字訊息"""
import requests
token = _get_bot_token()
if not token:
return False
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311] # fallback
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_ids[0], "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
if reply_markup:
payload["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
try:
r = requests.post(url, json=payload, timeout=10)
if not r.ok:
sys_log.warning("[TelegramTpl] sendMessage HTTP %s: %s", r.status_code, r.text[:200])
return False
return True
except Exception as e:
sys_log.error("[TelegramTpl] send 失敗: %s", e)
return False
def send_telegram_with_result(text: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None,
parse_mode: Optional[str] = "HTML") -> Dict[str, Any]:
"""發送 Telegram 並回傳結果明細,供 EventRouter / AIOps 使用。"""
token = _get_bot_token()
if not token:
return {"ok": False, "sent": 0, "failed": 0, "chat_ids": [], "errors": ["token_missing"]}
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311]
import requests
url = f"https://api.telegram.org/bot{token}/sendMessage"
sent = 0
failed = 0
errors: List[str] = []
for chat_id in chat_ids:
payload = {"chat_id": chat_id, "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
if reply_markup:
payload["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
try:
response = requests.post(url, json=payload, timeout=10)
if response.ok:
sent += 1
else:
failed += 1
errors.append(f"{chat_id}:HTTP {response.status_code}")
sys_log.warning("[TelegramTpl] sendMessage chat=%s HTTP %s: %s",
chat_id, response.status_code, response.text[:200])
except Exception as e:
failed += 1
errors.append(f"{chat_id}:{type(e).__name__}")
sys_log.error("[TelegramTpl] send chat=%s 失敗: %s", chat_id, e)
return {
"ok": sent > 0 and failed == 0,
"sent": sent,
"failed": failed,
"chat_ids": list(chat_ids),
"errors": errors,
}
def send_photo(photo_bytes: bytes, caption: str = "",
chat_ids: Optional[list] = None,
parse_mode: str = "HTML") -> bool:
"""發送圖片(含說明文字),供圖表報告使用"""
import requests
token = _get_bot_token()
if not token or not photo_bytes:
return False
if chat_ids is None:
chat_ids = _get_chat_ids()
if not chat_ids:
chat_ids = [-1003940688311]
url = f"https://api.telegram.org/bot{token}/sendPhoto"
try:
r = requests.post(
url,
data={"chat_id": chat_ids[0], "caption": caption[:1024],
"parse_mode": parse_mode},
files={"photo": ("chart.png", photo_bytes, "image/png")},
timeout=30,
)
if not r.ok:
sys_log.warning("[TelegramTpl] sendPhoto HTTP %s: %s", r.status_code, r.text[:200])
return False
return True
except Exception as e:
sys_log.error("[TelegramTpl] sendPhoto 失敗: %s", e)
return False
def send_report_with_charts(text_msg: str, charts: List[Optional[bytes]],
chat_ids: Optional[list] = None) -> bool:
"""先發文字報告,再逐張發送圖表"""
ok = _send_telegram_raw(text_msg, chat_ids=chat_ids)
for i, chart in enumerate(charts):
if chart:
send_photo(chart, caption=f"圖表 {i+1}/{len(charts)}", chat_ids=chat_ids)
return ok
# ══════════════════════════════════════════════════════════════════════════════
# 🚨 告警類模板
# ══════════════════════════════════════════════════════════════════════════════
def price_alert_msg(threats: List[Dict], analysis_period: str = "近48h") -> str:
"""
競品削價告警(Hermes + NemoTron 偵測結果)
┌─────────────────────────────────┐
│ 🚨 競品削價告警 · N 個 SKU │
│ ─────────────────────────────── │
│ ⚠️ SKU名稱 MOMO $X → 競品 $Y │
│ 價差 +X% 業績跌幅 -Y% │
└─────────────────────────────────┘
"""
n = len(threats)
high = [t for t in threats if t.get("risk") == "HIGH"]
lines = [
f"🚨 競品削價告警 · 偵測到 {n} 個 SKU [{analysis_period}]",
f"🔴 高危:{len(high)} 個 🟡 中危:{n - len(high)} 個",
"─" * 32,
]
for t in threats[:8]:
risk_icon = "🔴" if t.get("risk") == "HIGH" else "🟡"
name = str(t.get("name", t.get("sku", "")))[:22]
gap = float(t.get("gap_pct", 0))
delta = float(t.get("sales_7d_delta_pct", t.get("sales_delta", 0)))
momo_p = t.get("momo_price")
pchome_p = t.get("pchome_price")
price_str = f" MOMO ${momo_p:,.0f} vs 競品 ${pchome_p:,.0f}" \
if momo_p and pchome_p else ""
lines.append(
f"{risk_icon} {name}\n"
f" 價差 {gap:+.1f}% 業績週跌 {delta:+.1f}%{price_str}"
)
if n > 8:
lines.append(f"…另有 {n-8} 個 SKU(查看完整報告)")
lines += ["─" * 32,
"💡 建議:優先處理紅色高危品項,確認競品是否促銷或長期低價"]
return "\n".join(lines)
def threat_alert_msg(sku: str, name: str, momo_price: float,
comp_price: float, gap_pct: float,
sales_delta: float, action: str, confidence: float) -> str:
"""單品威脅告警(NemoTron 派發)"""
risk = "🔴 高危" if gap_pct > 15 else "🟡 中危" if gap_pct > 5 else "🟢 低危"
return (
f"{risk} 競品威脅通報\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🏷️ {name} {sku}\n\n"
f"💴 MOMO NT${momo_price:,.0f}\n"
f"💴 競品(PChome)NT${comp_price:,.0f}\n"
f"📉 價差 {gap_pct:+.1f}%(我方較貴)\n"
f"📊 業績週變 {sales_delta:+.1f}%\n\n"
f"🤖 AI 建議:{action}\n"
f"📈 信心度:{confidence:.0%}\n"
f"━━━━━━━━━━━━━━━━━━━━"
)
def system_alert_msg(level: str, title: str, detail: str, source: str = "") -> str:
"""系統級告警(AIOps / 部署失敗等)"""
icons = {"critical": "🚨", "error": "❌", "warning": "⚠️", "info": "ℹ️"}
icon = icons.get(level, "⚠️")
src = f" [{source}]" if source else ""
return (
f"{icon} {title}{src}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"{detail[:600]}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"
)
# ══════════════════════════════════════════════════════════════════════════════
# 📊 報告類模板(三種週期)
# ══════════════════════════════════════════════════════════════════════════════
def daily_report_header(date_str: str, revenue: float, wow: float,
threat_count: int, opportunity_count: int) -> str:
"""
日報標題卡(附圖前發送的文字說明)
"""
wow_icon = "📈" if wow >= 0 else "📉"
wow_color = "+" if wow >= 0 else ""
return (
f"📊 EwoooC 電商日報 · {date_str}\n"
f"══════════════════════════\n"
f"💰 今日業績 NT${revenue/10000:.1f} 萬\n"
f"{wow_icon} 週同比 {wow_color}{wow:.1f}%\n"
f"🔴 競品威脅 {threat_count} 個 SKU\n"
f"🟢 市場機會 {opportunity_count} 個 SKU\n"
f"══════════════════════════\n"
f"🤖 Hermes + NemoTron + OpenClaw 聯合分析"
)
def weekly_report_header(period: str, curr_rev: float, prev_rev: float,
wow: float, top_category: str) -> str:
"""週報標題卡"""
wow_icon = "📈" if wow >= 0 else "📉"
return (
f"📊 EwoooC 電商週報 · {period}\n"
f"══════════════════════════\n"
f"💰 本週業績 NT${curr_rev/10000:.1f} 萬\n"
f"📦 前週業績 NT${prev_rev/10000:.1f} 萬\n"
f"{wow_icon} 週成長率 {wow:+.1f}%\n"
f"🏆 最強品類 {top_category}\n"
f"══════════════════════════\n"
f"🤖 OpenClaw × MCP 全景策略分析"
)
def monthly_report_header(month_str: str, revenue: float, mom: float,
yoy: float, top3_categories: List[str]) -> str:
"""月報標題卡"""
mom_icon = "📈" if mom >= 0 else "📉"
yoy_icon = "🚀" if yoy >= 10 else "📈" if yoy >= 0 else "📉"
cats = " / ".join(top3_categories[:3])
return (
f"📅 EwoooC 電商月報 · {month_str}\n"
f"══════════════════════════\n"
f"💰 月度業績 NT${revenue/10000:.0f} 萬\n"
f"{mom_icon} 月成長率 {mom:+.1f}% {yoy_icon} 年成長 {yoy:+.1f}%\n"
f"🏆 TOP 3 品類 {cats}\n"
f"══════════════════════════\n"
f"🤖 OpenClaw × Hermes × MCP 月度全景洞察"
)
def report_section(icon: str, title: str, lines: List[str]) -> str:
"""通用報告節段(供日/週/月報各節使用)"""
body = "\n".join(f" {l}" for l in lines)
return f"\n{icon} {title}\n{body}"
# ══════════════════════════════════════════════════════════════════════════════
# 💰 決策類模板
# ══════════════════════════════════════════════════════════════════════════════
def price_decision(product_name: str, product_sku: str,
current_price: float, suggested_price: float,
reason: str, insight_id: Optional[int] = None) -> tuple:
"""降 / 提價決策通知(含 Inline Keyboard)"""
diff = current_price - suggested_price
action_text = f"降價 NT${diff:,.0f}" if diff > 0 else \
f"提價 NT${-diff:,.0f}" if diff < 0 else "維持現價"
direction = "📉" if diff > 0 else "📈" if diff < 0 else "➡️"
message = (
f"💰 AI 定價決策建議\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🏷️ {product_name} {product_sku}\n\n"
f"現價:NT${current_price:,.0f}\n"
f"建議:NT${suggested_price:,.0f} {direction} {action_text}\n\n"
f"💡 依據:{reason}\n"
)
if insight_id:
message += f"🔗 洞察 ID:{insight_id}\n"
message += f"━━━━━━━━━━━━━━━━━━━━"
# ADR-012: callback_data 採短 prefix(momo:pa/pr:{insight_id})— 64-byte 安全、與 L2 handler 一致
# 降級:若無 insight_id(不應發生,但保底),以 sku 做後綴 — sku 仍可能超長,呼叫者有責任提供 insight_id
_cb_id = str(insight_id) if insight_id else f"sku_{product_sku}"[:40]
keyboard = {"inline_keyboard": [[
{"text": "✅ 確認執行", "callback_data": f"momo:pa:{_cb_id}"},
{"text": "❌ 拒絕", "callback_data": f"momo:pr:{_cb_id}"},
]]}
return message, keyboard
def batch_decision_msg(items: List[Dict], batch_id: str) -> tuple:
"""批次定價決策(多 SKU 一次確認)"""
lines = [f"💰 批次定價決策 · 共 {len(items)} 個 SKU\n━━━━━━━━━━━━━━━━━━━━"]
for i, item in enumerate(items[:6], 1):
diff = item.get("current_price", 0) - item.get("suggested_price", 0)
direction = "📉" if diff > 0 else "📈"
lines.append(
f"{i}. {direction} {item.get('name','')[:20]}\n"
f" ${item.get('current_price',0):,.0f} → ${item.get('suggested_price',0):,.0f}"
)
if len(items) > 6:
lines.append(f"…另有 {len(items)-6} 個 SKU")
lines.append("━━━━━━━━━━━━━━━━━━━━")
# ADR-012: 短 prefix bpa=batch_price_approve / bpr=batch_price_reject,預留 64-byte buffer
_bid = str(batch_id)[:48]
keyboard = {"inline_keyboard": [[
{"text": f"✅ 全部確認({len(items)}項)",
"callback_data": f"momo:bpa:{_bid}"},
{"text": "❌ 取消",
"callback_data": f"momo:bpr:{_bid}"},
]]}
return "\n".join(lines), keyboard
# ══════════════════════════════════════════════════════════════════════════════
# 🧠 Phase 11 RAG 反饋(v5.0 護欄 #1:強制晉升門檻 — Stage 4 人工驗收)
# ══════════════════════════════════════════════════════════════════════════════
def rag_feedback_keyboard(rag_query_log_id: int) -> dict:
"""產生 RAG 命中後的 👍/👎 反饋鍵盤(callback prefix 'rag_fb:')。
callback_data 設計(與 ADR-012 短 prefix 規範一致,<= 64 byte):
rag_fb:{log_id}:5 → 👍(feedback_score=5)
rag_fb:{log_id}:1 → 👎(feedback_score=1)
使用範例(caller 拿 RAGResult 後):
from services.telegram_templates import rag_feedback_keyboard
send_message(chat_id, rag.synthesize(),
keyboard=rag_feedback_keyboard(rag.log_id))
"""
_id = int(rag_query_log_id) if rag_query_log_id else 0
return {
'inline_keyboard': [[
{'text': '👍 有用', 'callback_data': f'rag_fb:{_id}:5'},
{'text': '👎 沒用', 'callback_data': f'rag_fb:{_id}:1'},
]],
}
def promotion_review_keyboard(episode_id: int) -> dict:
"""蒸餾池高權重晉升人工驗收鍵盤(PromotionGate Stage 4)。
callback_data:
pg_ok:{episode_id} → 通過 → 寫 ai_insights
pg_no:{episode_id} → 駁回 → rejected_human
"""
_id = int(episode_id) if episode_id else 0
return {
'inline_keyboard': [[
{'text': '✅ 通過晉升', 'callback_data': f'pg_ok:{_id}'},
{'text': '🚫 駁回', 'callback_data': f'pg_no:{_id}'},
]],
}
# ══════════════════════════════════════════════════════════════════════════════
# 🤖 系統類模板
# ══════════════════════════════════════════════════════════════════════════════
def deploy_msg(status: str, branch: str, commit: str, duration: str = "") -> str:
"""CI/CD 部署通知"""
icons = {"success": "✅", "failed": "❌", "started": "🚀"}
icon = icons.get(status, "ℹ️")
dur = f" 耗時 {duration}" if duration else ""
return (
f"{icon} 部署{status.upper()}{dur}\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"🌿 分支:{branch}\n"
f"🔖 Commit:{commit[:12]}\n"
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"
)
def heal_msg(status: str, error_type: str, target_file: str,
commit_sha: str = "", diff_lines: int = 0) -> str:
"""AiderHeal 自動修復通知"""
if status == "started":
icon, title = "🔧", "AiderHeal 啟動"
elif status == "success":
icon, title = "✅", "AiderHeal 修復完成"
elif status == "reverted":
icon, title = "🔄", "AiderHeal 自動回滾"
else:
icon, title = "❌", "AiderHeal 失敗"
lines = [f"{icon} {title}", "━━━━━━━━━━━━━━━━━━━━",
f"🐛 錯誤類型:{error_type}",
f"📄 目標檔案:{target_file}"]
if commit_sha:
lines.append(f"🔖 Commit:{commit_sha[:12]}")
if diff_lines:
lines.append(f"📝 修改行數:{diff_lines} 行")
lines += ["━━━━━━━━━━━━━━━━━━━━",
f"🕐 {datetime.now().strftime('%m/%d %H:%M')}"]
return "\n".join(lines)
def meta_analysis_msg(period: str, content: str) -> str:
"""AI 系統自我審視報告"""
return (
f"🤖 AI 系統效能自我審視 · {period}\n"
f"══════════════════════════\n"
f"{content[:1200]}\n"
f"══════════════════════════\n"
f"由 OpenClaw 定期分析 · {datetime.now().strftime('%m/%d %H:%M')}"
)
# ══════════════════════════════════════════════════════════════════════════════
# 💡 洞察類模板
# ══════════════════════════════════════════════════════════════════════════════
def triaged_alert(base_event: Dict[str, Any], tier_label: str,
ai_summary: str, ai_cause: Optional[str] = None,
ai_actions: Optional[list] = None,
ai_executed: Optional[list] = None) -> tuple:
"""EA L1/L2 自主執行通知(保留原有介面,升級排版)"""
event_type = base_event.get("event_type", "alert")
title = escape(str(base_event.get("title", "")))
summary = escape(str(base_event.get("summary", "")))
event_id = base_event.get("id", "unknown")
safe_ai_summary = escape(str(ai_summary or ""))
safe_ai_cause = escape(str(ai_cause or "")) if ai_cause else None
safe_actions = [escape(str(a)) for a in (ai_actions or [])]
safe_executed = [escape(str(a)) for a in (ai_executed or [])]
lines = [
f"⚡ {tier_label} · {event_type}",
f"📌 {title}",
"",
]
if summary:
lines += [f"🔍 概要:{summary}", ""]
if safe_ai_summary:
lines += [f"🧠 AI 摘要:{safe_ai_summary[:400]}", ""]
if safe_ai_cause:
lines += [f"💡 可能原因:{safe_ai_cause}", ""]
if safe_actions:
lines += ["📋 建議行動:"] + [f" • {a}" for a in safe_actions] + [""]
if safe_executed:
lines += ["✅ 已執行:"] + [f" • {a}" for a in safe_executed] + [""]
trace = base_event.get("trace")
if trace:
lines.append(f"
{trace[-400:]}")
# ADR-012: eig=event_ignore,event_id 截斷確保 ≤ 60 bytes(留 buffer)
_eid = str(event_id)[:52]
keyboard = {"inline_keyboard": [
[{"text": "🛑 忽略此事件",
"callback_data": f"momo:eig:{_eid}"}],
]}
return "\n".join(lines), keyboard
def insight_summary_msg(insights: List[Dict], period: str = "近24h") -> str:
"""AI 洞察摘要彙整(供定期推播)"""
n = len(insights)
lines = [
f"💡 AI 洞察摘要 · {period} 共 {n} 筆",
"━━━━━━━━━━━━━━━━━━━━",
]
type_icons = {
"price_alert": "🔴", "recommendation": "💰", "weekly_strategy": "📊",
"meta_analysis": "🤖", "market_opportunity": "🟢", "mcp_cache": "🌐",
}
for ins in insights[:6]:
icon = type_icons.get(ins.get("insight_type", ""), "💡")
content_preview = str(ins.get("content", ""))[:80].replace("\n", " ")
conf = float(ins.get("confidence", 0))
lines.append(f"{icon} {content_preview}… ({conf:.0%})")
if n > 6:
lines.append(f"…另有 {n-6} 筆洞察")
lines.append("━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════════════════
# 舊版相容介面(保留供現有程式碼調用)
# ══════════════════════════════════════════════════════════════════════════════
def alert(title: str, content: str, actions: Optional[list] = None) -> str:
msg = system_alert_msg("error", title, content)
if actions:
msg += "\n" + "\n".join(f" • {a}" for a in actions)
return msg
def warning(title: str, summary: str, details: Optional[dict] = None) -> str:
detail_str = "\n".join(f" {k}: {v}" for k, v in (details or {}).items())
return system_alert_msg("warning", title, summary + ("\n" + detail_str if detail_str else ""))
def info(title: str, module: str, content: str, time: Optional[Any] = None) -> str:
t_str = f" · {time}" if time else ""
return f"ℹ️ {title} [{module}]{t_str}\n\n{content}"
def success(title: str, module: str, stats: str = "") -> str:
return f"✅ {title} [{module}]\n{stats}"
def report(title: str, report_type: str, period: str, content_md: str) -> str:
icons = {"weekly_strategy": "📊", "daily": "📅", "monthly": "📆", "meta_analysis": "🤖"}
icon = icons.get(report_type, "📋")
return (
f"{icon} {title} ({report_type})\n"
f"📅 期間:{period}\n"
f"══════════════════════════\n"
f"{content_md}"
)
def _send_telegram(msg: str, chat_ids: Optional[list] = None,
reply_markup: Optional[Dict[str, Any]] = None) -> bool:
return _send_telegram_raw(msg, chat_ids=chat_ids, reply_markup=reply_markup)
# ══════════════════════════════════════════════════════════════════════════════
# LLM Token 日報模板(Operation Ollama-First v5.0 — Phase 1 收尾)
# 對應 services/token_report_service.py
# ══════════════════════════════════════════════════════════════════════════════
# Telegram 單訊息上限(保險絲,sendMessage HTML 上限為 4096 字元)
_DAILY_TOKEN_REPORT_MAX_CHARS = 4096
def daily_token_report(report_html: str,
footer_url: Optional[str] = None) -> str:
"""LLM Token 日報訊息包裝(HTML parse_mode)。
本函數只負責「附上 footer + 截斷至 Telegram 上限」;報表本體由
services/token_report_service.generate_daily_report() 產出,已含 HTML escape。
Args:
report_html: 已 escape 的 HTML 報表字串
footer_url: 選填 admin 後台連結,會自動 escape
Returns:
≤ 4096 字元的 HTML 字串,安全送 Telegram
"""
body = report_html or ""
if footer_url:
body = f"{body}\n📎 詳細日誌"
if len(body) <= _DAILY_TOKEN_REPORT_MAX_CHARS:
return body
# 超長 → 截斷並加省略尾(保留 80 字給 trailing notice)
truncated = body[: _DAILY_TOKEN_REPORT_MAX_CHARS - 80]
return truncated + "\n\n... (訊息過長已截斷;完整內容存於 ai_insights)"
# ══════════════════════════════════════════════════════════════════════════════
# 決策回執模板(L2 / L3 按鈕點擊後編輯原訊息使用)
# ADR-012 Phase 4:審計留痕 — 操作者、時間、action、結果
# ══════════════════════════════════════════════════════════════════════════════
# Asia/Taipei(UTC+8)— 容器可能跑 UTC,這裡手動偏移避免依賴 tzdata
_TAIPEI_UTC_OFFSET_HOURS = 8
def _now_taipei_hhmm() -> str:
"""回傳 Asia/Taipei 的 HH:MM(容器 TZ 不可靠,手動加 8h)。"""
try:
from datetime import timedelta, timezone
tz = timezone(timedelta(hours=_TAIPEI_UTC_OFFSET_HOURS))
return datetime.now(tz).strftime("%H:%M")
except Exception:
return datetime.now().strftime("%H:%M")
def _html_escape(s: Any) -> str:
"""最小 HTML 轉義(Telegram HTML parse_mode 允許 ,
故只轉 < > & 避免使用者輸入破版)。"""
text = "" if s is None else str(s)
return (text.replace("&", "&")
.replace("<", "<")
.replace(">", ">"))
def decision_result(original_text: str, action: str, operator: str,
note: Optional[str] = None, **extras: Any) -> str:
"""L2 單品定價決策回執(approve / reject 後編輯原訊息)。
Args:
original_text: 原 Telegram 訊息文字(query.message.text)
action: "approve" / "reject"
operator: 操作者顯示名稱(user.full_name 或 id_)
note: 選填補充說明(例:訓練保守策略)
**extras: 前向相容預留(目前忽略)
Returns:
原訊息 + 分隔線 + 稽核區塊(HTML parse_mode 安全)
"""
act = (action or "").lower()
if act == "approve":
icon, label = "✅", "已執行"
elif act == "reject":
icon, label = "❌", "已拒絕"
else:
icon, label = "ℹ️", f"已處理({_html_escape(action)})"
ts = _now_taipei_hhmm()
op_esc = _html_escape(operator or "unknown")
base = original_text or ""
lines = [
base,
"",
"━━━━━━━━━━━━━━━━━━━━",
f"{icon} {label} by {op_esc} at {ts}",
]
if note:
lines.append(f"📝 {_html_escape(note)}")
return "\n".join(lines)
def ops_action_result(original_text: str, action: str, operator: str,
result: Optional[Dict[str, Any]] = None,
**extras: Any) -> str:
"""L3 運維決策回執(pause1h / pause6h / retry / resume 執行後編輯原訊息)。
Args:
original_text: 原 Telegram 訊息文字
action: "pause1h" / "pause6h" / "retry" / "resume" / 其他
operator: 操作者顯示名稱
result: OPS_ACTIONS 函數回傳 dict,常見欄位:
status: "ok" / "success" / "error" / "skipped"
error: 錯誤訊息(若 status=error)
message / detail: 成功訊息
task_name, duration_min 等
**extras: 前向相容預留
Returns:
原訊息 + 分隔線 + 運維執行結果區塊
"""
action_labels = {
"pause1h": "暫停 1 小時",
"pause6h": "暫停 6 小時",
"retry": "立即重試",
"resume": "恢復執行",
"execute": "執行",
"skip": "略過",
"rollback": "回滾",
}
act_label = action_labels.get((action or "").lower(), _html_escape(action or "未知動作"))
result = result or {}
status = str(result.get("status", "")).lower()
if status in ("ok", "success", "done"):
status_icon, status_text = "✅", "成功"
elif status in ("error", "failed", "fail"):
status_icon, status_text = "❌", "失敗"
elif status == "skipped":
status_icon, status_text = "⏭️", "已略過"
else:
status_icon, status_text = "ℹ️", status or "已提交"
ts = _now_taipei_hhmm()
op_esc = _html_escape(operator or "unknown")
# 擷取結果訊息(err > message > detail)
detail_msg: Optional[str] = None
for key in ("error", "message", "detail"):
if result.get(key):
detail_msg = str(result[key])[:300]
break
lines = [
original_text or "",
"",
"━━━━━━━━━━━━━━━━━━━━",
f"🛠️ 運維動作:{act_label}",
f"👤 操作員:{op_esc} 🕐 {ts}",
f"{status_icon} 執行結果:{_html_escape(status_text)}",
]
if detail_msg:
lines.append(f"📋 {_html_escape(detail_msg)}")
# 額外關鍵欄位(task_name / duration_min 等)
task_name = result.get("task_name")
if task_name:
lines.append(f"📌 任務:{_html_escape(task_name)}")
duration_min = result.get("duration_min")
if duration_min:
lines.append(f"⏱️ 時長:{_html_escape(duration_min)} 分鐘")
return "\n".join(lines)