fix: Telegram HTML 解析錯誤 + 簽核後內容保留
修復: 1. telegram_gateway.py - HTML 轉義 (html.escape) 防止 "Can't parse entities" 2. openclaw-state-machine.tsx - 簽核後顯示結果 2 秒再導航 問題根因: - URL 和用戶輸入內容可能包含 <, >, & 破壞 HTML - 簽核後立即刷新列表,已簽核項目消失 Memory: feedback_approval_preserve_content.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,13 @@ SOUL.md 鐵律 (4.1 Telegram 訊息壓縮原則):
|
||||
- 根因摘要: 100 字元
|
||||
- 建議行動: 50 字元
|
||||
- 總長度: 800 字元 (v7.0 擴展以容納 SignOz 區塊)
|
||||
|
||||
修復紀錄:
|
||||
- 2026-03-26 Claude Code: 修復 HTML 解析錯誤 (Can't parse entities)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -156,31 +160,38 @@ class TelegramMessage:
|
||||
# 自動生成事件編號
|
||||
incident_id = self.incident_id or f"INC-{self.approval_id[:8].upper()}"
|
||||
|
||||
# SignOz URL (優先使用動態 URL)
|
||||
# SignOz URL (優先使用動態 URL) - 必須 HTML 轉義防止解析錯誤
|
||||
service_name = self.resource_name.split("-")[0] if "-" in self.resource_name else self.resource_name
|
||||
signoz_url = self.signoz_trace_url or f"http://192.168.0.188:3301/traces?service={service_name}"
|
||||
raw_url = self.signoz_trace_url or f"http://192.168.0.188:3301/traces?service={service_name}"
|
||||
signoz_url = html.escape(raw_url, quote=True)
|
||||
|
||||
# SignOz 指標區塊
|
||||
signoz_block = ""
|
||||
if self.signoz_metrics:
|
||||
signoz_block = f"━━━━━━━━━━━━━━━━━━━\n{self.signoz_metrics.format()}\n"
|
||||
|
||||
# HTML 轉義用戶輸入內容,防止 "Can't parse entities" 錯誤
|
||||
safe_resource = html.escape(self.resource_name[:35])
|
||||
safe_root_cause = html.escape(self.root_cause[:50])
|
||||
safe_action = html.escape(self.suggested_action[:35])
|
||||
safe_downtime = html.escape(self.estimated_downtime)
|
||||
|
||||
# 組裝訊息
|
||||
message = (
|
||||
f"═══════════════════════════\n"
|
||||
f"{self.status_emoji} <b>{self.risk_level}</b> | {self.resource_name[:25]}\n"
|
||||
f"{self.status_emoji} <b>{html.escape(self.risk_level)}</b> | {html.escape(self.resource_name[:25])}\n"
|
||||
f"═══════════════════════════\n"
|
||||
f"📋 <code>{incident_id}</code>\n"
|
||||
f"🎯 資源: <code>{self.resource_name[:35]}</code>\n"
|
||||
f"📋 <code>{html.escape(incident_id)}</code>\n"
|
||||
f"🎯 資源: <code>{safe_resource}</code>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"🤖 <b>AI 仲裁判定</b>\n"
|
||||
f"👥 責任: {resp_display}\n"
|
||||
f"📊 信心: {conf_emoji} {confidence_pct}%\n"
|
||||
f"💡 原因: {self.root_cause[:50]}\n"
|
||||
f"💡 原因: {safe_root_cause}\n"
|
||||
f"{signoz_block}"
|
||||
f"━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"🔧 建議: {self.suggested_action[:35]}\n"
|
||||
f"⏱️ 停機: {self.estimated_downtime}\n"
|
||||
f"🔧 建議: {safe_action}\n"
|
||||
f"⏱️ 停機: {safe_downtime}\n"
|
||||
f"🔍 <a href='{signoz_url}'>查看 SignOz Trace (±5min)</a>"
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,14 @@ export function OpenClawStateMachine({
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const selectedApproval = selectedIndex !== null ? pendingApprovals[selectedIndex] : null
|
||||
|
||||
// 2026-03-26: 簽核後保留內容顯示 (feedback_approval_preserve_content.md)
|
||||
// 儲存剛簽核/拒絕的項目,顯示結果後再清除
|
||||
const [processedApproval, setProcessedApproval] = useState<{
|
||||
approval: ApprovalRequest
|
||||
action: 'approved' | 'rejected'
|
||||
timestamp: Date
|
||||
} | null>(null)
|
||||
|
||||
// Timer refs for cleanup
|
||||
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -416,35 +424,95 @@ export function OpenClawStateMachine({
|
||||
|
||||
{/* Approval Modal - 全屏審核對話框 (Phase 17 UI/UX 修復) */}
|
||||
<ApprovalModal
|
||||
open={selectedIndex !== null}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
title="審核詳情"
|
||||
onPrev={handlePrevApproval}
|
||||
onNext={handleNextApproval}
|
||||
current={selectedIndex !== null ? selectedIndex + 1 : undefined}
|
||||
total={pendingApprovals.length}
|
||||
open={selectedIndex !== null || processedApproval !== null}
|
||||
onClose={() => {
|
||||
setSelectedIndex(null)
|
||||
setProcessedApproval(null)
|
||||
}}
|
||||
title={processedApproval ? (processedApproval.action === 'approved' ? '✅ 已批准' : '❌ 已拒絕') : '審核詳情'}
|
||||
onPrev={processedApproval ? undefined : handlePrevApproval}
|
||||
onNext={processedApproval ? undefined : handleNextApproval}
|
||||
current={selectedIndex !== null && !processedApproval ? selectedIndex + 1 : undefined}
|
||||
total={!processedApproval ? pendingApprovals.length : undefined}
|
||||
>
|
||||
{selectedApproval && (
|
||||
{/* 2026-03-26: 顯示剛處理的項目結果 */}
|
||||
{processedApproval && (
|
||||
<div className="relative">
|
||||
{/* 結果覆蓋層 */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 z-10 flex items-center justify-center rounded-xl",
|
||||
processedApproval.action === 'approved'
|
||||
? "bg-status-healthy/10"
|
||||
: "bg-status-critical/10"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"text-center px-6 py-4 rounded-lg backdrop-blur-sm",
|
||||
processedApproval.action === 'approved'
|
||||
? "bg-status-healthy/20 text-status-healthy"
|
||||
: "bg-status-critical/20 text-status-critical"
|
||||
)}>
|
||||
<div className="text-4xl mb-2">
|
||||
{processedApproval.action === 'approved' ? '✅' : '❌'}
|
||||
</div>
|
||||
<div className="font-bold text-lg">
|
||||
{processedApproval.action === 'approved' ? '已批准執行' : '已拒絕執行'}
|
||||
</div>
|
||||
<div className="text-sm opacity-75 mt-1">
|
||||
{processedApproval.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 原始內容 (保留顯示) */}
|
||||
<ApprovalCard
|
||||
request={processedApproval.approval}
|
||||
onApprove={async () => {}}
|
||||
onReject={async () => {}}
|
||||
holdDuration={1000}
|
||||
fullHeight
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 正常待審核項目 */}
|
||||
{selectedApproval && !processedApproval && (
|
||||
<ApprovalCard
|
||||
request={selectedApproval}
|
||||
onApprove={async () => {
|
||||
// 2026-03-26: 保留簽核後內容顯示 2 秒
|
||||
setProcessedApproval({
|
||||
approval: selectedApproval,
|
||||
action: 'approved',
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
const newCount = await handleApprove(selectedApproval.id) ?? 0
|
||||
// 簽核後: 根據 API 返回的新數量決定導航
|
||||
if (newCount > 0 && selectedIndex !== null) {
|
||||
// 還有待審核項目,保持 Modal 開啟
|
||||
// 如果當前 index 超出範圍,調整到最後一筆
|
||||
if (selectedIndex >= newCount) {
|
||||
setSelectedIndex(newCount - 1)
|
||||
|
||||
// 顯示結果 2 秒後導航
|
||||
setTimeout(() => {
|
||||
setProcessedApproval(null)
|
||||
if (newCount > 0 && selectedIndex !== null) {
|
||||
if (selectedIndex >= newCount) {
|
||||
setSelectedIndex(newCount - 1)
|
||||
}
|
||||
} else {
|
||||
setSelectedIndex(null)
|
||||
}
|
||||
// 否則保持 index 不變,列表刷新後自動顯示下一筆
|
||||
} else {
|
||||
// 沒有待審核項目了,關閉 Modal
|
||||
setSelectedIndex(null)
|
||||
}
|
||||
}, 2000)
|
||||
}}
|
||||
onReject={async () => {
|
||||
// 2026-03-26: 保留拒絕後內容顯示 2 秒
|
||||
setProcessedApproval({
|
||||
approval: selectedApproval,
|
||||
action: 'rejected',
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
await handleReject(selectedApproval.id)
|
||||
setSelectedIndex(null)
|
||||
|
||||
// 顯示結果 2 秒後關閉
|
||||
setTimeout(() => {
|
||||
setProcessedApproval(null)
|
||||
setSelectedIndex(null)
|
||||
}, 2000)
|
||||
}}
|
||||
holdDuration={1000}
|
||||
fullHeight
|
||||
|
||||
Reference in New Issue
Block a user