fix(telegram): 修復死卡按鈕 + 重複渲染 + 智能截斷三連修
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 10m26s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 10m26s
問題 1 — 批准/拒絕按鈕消失(死卡)
根因:_build_inline_keyboard 有 alert_category 動態按鈕時走 category 路徑,
approve/reject 行被跳過 → requires_human_approval 卡片無審核扳機
修復:新增 requires_human_approval 參數;True 時強制在動態按鈕後插入批准/拒絕行
影響:decision_manager 傳入 proposal_data.requires_human_review
問題 2 — TYPE-8M 三欄重複渲染
根因:diagnosis/system_impact/probable_cause 全用 reasoning[:100] → 同一段字
修復:新增 _parse_debate_summary(),拆分 debate_summary 的「診斷/方案/安全審查/質疑」
各欄位填入不同語意的組件
問題 3 — 幽靈截斷「質疑:無(通」
根因:粗暴 [:N] 在括號/中文字中間切斷
修復:新增 _smart_truncate(),在句子邊界(。!?;,)截斷,補 …[截斷] 標記
驗證:verify_telegram_ui.py 全部通過(括號平衡 ✅、欄位不重複 ✅、按鈕存在 ✅)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,7 @@ async def _push_decision_to_telegram(
|
||||
classify_notification,
|
||||
get_telegram_gateway,
|
||||
NotificationType,
|
||||
_smart_truncate as _smt,
|
||||
)
|
||||
|
||||
# 🔴 去重檢查:同一個 incident 10 分鐘內只發一次
|
||||
@@ -167,6 +168,27 @@ async def _push_decision_to_telegram(
|
||||
"""移除 <placeholder> 佔位符,避免 Telegram HTML parse 錯誤"""
|
||||
return _re.sub(r'<[^>]+>', '', s).strip()
|
||||
|
||||
def _parse_debate_summary(reasoning: str) -> dict[str, str]:
|
||||
"""
|
||||
解析 coordinator debate_summary → {diagnosis, plan, review, critic}
|
||||
|
||||
格式:「診斷:{...};方案:{...};安全審查:{...};質疑:{...}」
|
||||
2026-04-17 ogt + Claude Sonnet 4.6: 修復 TYPE-8M 三欄重複渲染
|
||||
根因:diagnosis/system_impact/probable_cause 全用 reasoning[:100] → 同一段字
|
||||
"""
|
||||
result: dict[str, str] = {"diagnosis": "", "plan": "", "review": "", "critic": ""}
|
||||
for part in reasoning.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("診斷:"):
|
||||
result["diagnosis"] = part[3:]
|
||||
elif part.startswith("方案:"):
|
||||
result["plan"] = part[3:]
|
||||
elif part.startswith("安全審查:"):
|
||||
result["review"] = part[5:]
|
||||
elif part.startswith("質疑:"):
|
||||
result["critic"] = part[3:]
|
||||
return result
|
||||
|
||||
target = incident.affected_services[0] if incident.affected_services else "unknown"
|
||||
risk_level = proposal_data.get("risk_level", "medium")
|
||||
# 2026-04-09 Claude Code: action 不用 _strip_placeholders,避免截掉 deployment name
|
||||
@@ -311,15 +333,21 @@ async def _push_decision_to_telegram(
|
||||
)
|
||||
elif _notif_type == NotificationType.TYPE_8M or _alert_category in ("alertchain_health", "flywheel_health"):
|
||||
# TYPE-8M:飛輪/告警鏈路健康異常,發到個人 DM(不發群組)
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6: 解析 debate_summary,各欄位用不同組件
|
||||
# 根因:diagnosis/system_impact/probable_cause 全取 reasoning[:100] → 三欄重複同一段字
|
||||
_parsed = _parse_debate_summary(reasoning)
|
||||
_diag = _smt(_parsed.get("diagnosis") or description, 120) if (_parsed.get("diagnosis") or description) else "(無診斷)"
|
||||
_impact = _smt(_parsed.get("plan") or "", 150)
|
||||
_cause = _smt(_parsed.get("critic") or _parsed.get("review") or "", 100)
|
||||
tg_result = await gateway.send_meta_alert(
|
||||
incident_id=incident.incident_id,
|
||||
approval_id=approval_id,
|
||||
alertname=_alertname,
|
||||
alert_category=_alert_category,
|
||||
diagnosis=reasoning[:100] if reasoning else description[:100],
|
||||
diagnosis=_diag,
|
||||
severity_level=risk_level,
|
||||
system_impact=description[:150] if description else "",
|
||||
probable_cause=reasoning[:100] if reasoning else "",
|
||||
system_impact=_impact,
|
||||
probable_cause=_cause,
|
||||
)
|
||||
elif _alert_category == "secops":
|
||||
# TYPE-5S:資安事件 — 隔離/封鎖審核卡,發到個人 DM (ADR-075 Step-5)
|
||||
@@ -349,11 +377,15 @@ async def _push_decision_to_telegram(
|
||||
)
|
||||
else:
|
||||
# TYPE-2 / TYPE-3 / TYPE-4 都走 send_approval_card(按鈕組合由 alert_category 決定)
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6: 傳入 requires_human_approval + 智能截斷
|
||||
# 根因:① requires_human_approval=True 時動態按鈕卡缺少 [批准][拒絕] → 死卡
|
||||
# ② [:500] 在括號中間切斷 → 幽靈截斷「質疑:無(通」
|
||||
_requires_human = bool(proposal_data.get("requires_human_review", False))
|
||||
tg_result = await gateway.send_approval_card(
|
||||
approval_id=approval_id,
|
||||
risk_level=risk_level,
|
||||
resource_name=target[:50],
|
||||
root_cause=reasoning[:500] if reasoning else description[:500],
|
||||
root_cause=_smt(reasoning, 500) if reasoning else _smt(description, 500),
|
||||
suggested_action=action[:120] if action else (description[:120] if description else "待分析"),
|
||||
estimated_downtime="5-15 min",
|
||||
primary_responsibility="INFRA",
|
||||
@@ -371,6 +403,7 @@ async def _push_decision_to_telegram(
|
||||
alert_category=_alert_category,
|
||||
notification_type=_notification_type,
|
||||
playbook_name=_playbook_name,
|
||||
requires_human_approval=_requires_human,
|
||||
)
|
||||
|
||||
# 2026-04-09 Claude Sonnet 4.6: 存 message_id → 後續狀態更新在原訊息延續
|
||||
|
||||
@@ -65,6 +65,31 @@ logger = structlog.get_logger(__name__)
|
||||
_tracer = trace.get_tracer("awoooi.telegram_gateway", "1.0.0")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 智能截斷 (2026-04-17 ogt + Claude Sonnet 4.6 — ADR-075 修復)
|
||||
# 根因:粗暴 [:N] 在括號/中文字中間切斷 → 幽靈截斷「質疑:無(通」
|
||||
# 規則:在完整句子邊界截斷;若無邊界則補 …[截斷] 標記
|
||||
# =============================================================================
|
||||
|
||||
def _smart_truncate(text: str, limit: int, suffix: str = "…[截斷]") -> str:
|
||||
"""
|
||||
在句子邊界截斷文字,防止破壞括號閉合或切斷中文字。
|
||||
|
||||
優先序:。!? > ; > ,、, > 空白
|
||||
若在合理位置(>50% limit)找到邊界 → 在邊界後截斷
|
||||
否則 → 在 limit 處截斷並加 suffix
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
# 依優先序嘗試各邊界字元
|
||||
for boundary in ("。", "!", "?", ";", ",", "、", ",", " "):
|
||||
pos = text.rfind(boundary, 0, limit)
|
||||
if pos >= limit // 2: # 至少在一半後才算有效邊界
|
||||
return text[:pos + len(boundary)] + suffix
|
||||
# 無邊界:硬截 + 標記
|
||||
return text[:limit] + suffix
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Long Polling 配置 (Phase 5 內網修復)
|
||||
# =============================================================================
|
||||
@@ -1397,6 +1422,9 @@ class TelegramGateway:
|
||||
# ADR-071-E: TYPE-3 動態按鈕 (2026-04-11 Claude Sonnet 4.6)
|
||||
alert_category: str = "",
|
||||
notification_type: str = "",
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6: requires_human_approval 強制附加批准/拒絕行
|
||||
# 根因:有動態按鈕時走 category 路徑,approve/reject 被漏掉 → 卡片成「死卡」
|
||||
requires_human_approval: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
建立 Inline Keyboard
|
||||
@@ -1460,6 +1488,13 @@ class TelegramGateway:
|
||||
]
|
||||
# 每行最多 3 個,超過換行
|
||||
rows = [category_btns[i:i+3] for i in range(0, len(category_btns), 3)]
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6: requires_human_approval → 必須附加批准/拒絕行
|
||||
# 根因:有動態按鈕時舊邏輯只有 [詳情][忽略],SRE 找不到審核扳機 → 死卡
|
||||
if requires_human_approval:
|
||||
rows.append([
|
||||
{"text": "✅ 批准", "callback_data": approve_nonce},
|
||||
{"text": "❌ 拒絕", "callback_data": reject_nonce},
|
||||
])
|
||||
# 通用操作:[查看詳情] [忽略]
|
||||
rows.append([
|
||||
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
|
||||
@@ -1536,6 +1571,8 @@ class TelegramGateway:
|
||||
notification_type: str = "",
|
||||
# 2026-04-16 ogt + Claude Sonnet 4.6: 修復鏈路顯示 (ADR-076)
|
||||
playbook_name: str = "",
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6: 強制在動態按鈕卡上加批准/拒絕行
|
||||
requires_human_approval: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
推送待簽核卡片到 Telegram (v7.0 含 SignOz 整合)
|
||||
@@ -1627,6 +1664,7 @@ class TelegramGateway:
|
||||
incident_id=incident_id,
|
||||
alert_category=alert_category,
|
||||
notification_type=notification_type,
|
||||
requires_human_approval=requires_human_approval,
|
||||
)
|
||||
|
||||
# 發送訊息
|
||||
|
||||
Reference in New Issue
Block a user