fix(telegram): TG-2 + TG-4 修 drift 按鈕 black hole
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

2026-04-19 凌晨(台北時區)— ogt + Claude Opus 4.7 (1M)

統帥截圖直擊: 按「查看 Diff」→ 變成「執行中」,且看不到還有 21 項。

全景盤點發現 9 個 Telegram 子系統 bug,本 commit 修 2 個最痛的:

## TG-2: drift_view/drift_adopt/drift_revert 3 按鈕**無 handler**
  點擊 → fallthrough → UX 黑洞 / 誤觸發 approve 路徑。

修復: handle_callback 在 state guard 後(line 2752 後)加 Step 1.85
  offroute: 3 個 drift_* action → _handle_drift_action 專職處理,
  不走 nonce approve/reject dispatch,避免誤觸發執行流。

3 個按鈕實作:
  - drift_view: 讀 drift_reports → 送新訊息展示全部 items
    (HIGH/MEDIUM/INFO emoji + Git vs K8s 原值對照,上限 50 項 4000 字)
  - drift_adopt: 呼叫 drift_adopt_service.adopt_drift()
  - drift_revert: 呼叫 drift_remediator.revert()

## TG-4: drift card message_id 沒存 Redis → edit 回不了卡片
修復: send_drift_card 成功後 setex f"tg_drift:{incident_id}" TTL 24h,
  供 _edit_drift_card_outcome 在 adopt/revert 執行後更新原卡片(先移除
  按鈕 + 加「XX by @username (成功/失敗)」簽核戳)。

## 未包含(follow-up):
  TG-1 INFO_ACTIONS 擴充(view)  — 下一 commit
  TG-3 handler 重複分派 — 評估中
  TG-5 Bot webhook URL 未設 — 需統帥決策公開 URL
  approval card NO_ACTION 誤標 FAILED — 下一 commit
  approval card description 矛盾 / responsibility 未知 / 執行後 edit

全景 9 bug 清單詳見 project_phase7_round3_telegram_subsystem_audit(待建)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-19 01:06:28 +08:00
parent 41e6b503e2
commit 877c8479e0

View File

@@ -1964,7 +1964,7 @@ class TelegramGateway:
]
}
return await self._send_request(
_result = await self._send_request(
"sendMessage",
{
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
@@ -1974,6 +1974,152 @@ class TelegramGateway:
},
)
# 2026-04-19 ogt + Claude Opus 4.7: 修 TG-4 存 drift message_id 到 Redis
# 供 drift_adopt/drift_revert 執行後 edit 回原卡片
try:
_msg_id = _result.get("result", {}).get("message_id")
if _msg_id:
await get_redis().setex(
f"tg_drift:{incident_id}", 86400, str(_msg_id)
)
except Exception as _e:
logger.warning("tg_drift_msg_id_store_failed", incident_id=incident_id, error=str(_e))
return _result
# =========================================================================
# 2026-04-19 ogt + Claude Opus 4.7: drift_* 按鈕 handler (修 TG-2)
# =========================================================================
async def _handle_drift_action(
self,
action: str,
approval_id: str,
callback_query_id: str,
user_id: int,
username: str,
user: dict,
) -> dict:
"""
處理 drift_view / drift_adopt / drift_revert 按鈕。
approval_id 在 drift card 即 report_id (send_drift_card 設計)。
"""
report_id = approval_id
try:
if action == "drift_view":
await self._answer_callback(callback_query_id, action, text="🔍 撈全部 Diff...")
await self._send_drift_diff_detail(report_id)
return {
"action": action, "approval_id": approval_id,
"user": user, "success": True, "info_action": True,
}
if action == "drift_adopt":
await self._answer_callback(callback_query_id, action, text="✅ 採納中...")
try:
from src.services.drift_adopt_service import get_drift_adopt_service
_adopt_result = await get_drift_adopt_service().adopt_drift(report_id)
_ok = bool(_adopt_result.get("success") if isinstance(_adopt_result, dict) else _adopt_result)
except Exception as _e:
logger.warning("drift_adopt_failed", report_id=report_id, error=str(_e))
_ok = False
await self._edit_drift_card_outcome(
report_id=report_id, verb="已採納", by=username, ok=_ok,
)
return {"action": action, "approval_id": approval_id, "user": user, "success": _ok}
if action == "drift_revert":
await self._answer_callback(callback_query_id, action, text="⏪ 回滾中...")
try:
from src.services.drift_remediator import get_drift_remediator
_revert_result = await get_drift_remediator().revert(report_id)
_ok = bool(_revert_result.get("success") if isinstance(_revert_result, dict) else _revert_result)
except Exception as _e:
logger.warning("drift_revert_failed", report_id=report_id, error=str(_e))
_ok = False
await self._edit_drift_card_outcome(
report_id=report_id, verb="已回滾", by=username, ok=_ok,
)
return {"action": action, "approval_id": approval_id, "user": user, "success": _ok}
except Exception as _outer:
logger.exception("drift_action_handler_error", action=action, error=str(_outer))
return {"action": action, "approval_id": approval_id, "user": user, "success": False}
async def _send_drift_diff_detail(self, report_id: str) -> None:
"""
送完整 Drift Diff 到 Telegram (drift_view 按鈕回應)
展示全部 items (含 HIGH + MEDIUM + 可操作+trivial 分群)
"""
try:
from src.repositories.drift_repository import get_drift_repository
_rpt = await get_drift_repository().get_by_id(report_id)
if not _rpt:
await self._send_request("sendMessage", {
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
"text": f"⚠️ 找不到 Drift report <code>{html.escape(report_id)}</code>",
"parse_mode": "HTML",
})
return
_lines = [f"📊 <b>完整 Drift Diff</b> — <code>{html.escape(report_id)}</code>"]
_lines.append(f"Namespace: <code>{html.escape(_rpt.namespace)}</code>")
_lines.append(f"HIGH×{_rpt.high_count} MEDIUM×{_rpt.medium_count} INFO×{_rpt.info_count}")
_lines.append("" * 20)
for i, _item in enumerate(_rpt.items[:50], 1):
_level = getattr(_item.drift_level, "value", str(_item.drift_level))
_emoji = "🔴" if _level == "high" else ("🟡" if _level == "medium" else "")
_field = (_item.field_path or "")[:80]
_git = str(_item.git_value)[:40] if _item.git_value is not None else "(未設)"
_k8s = str(_item.actual_value)[:40] if _item.actual_value is not None else "(未設)"
_lines.append(f"{_emoji} <b>{html.escape(_field)}</b>")
_lines.append(f" Git: <code>{html.escape(_git)}</code>")
_lines.append(f" K8s: <code>{html.escape(_k8s)}</code>")
if len(_rpt.items) > 50:
_lines.append(f"… 還有 {len(_rpt.items) - 50} 項未顯示")
_full = "\n".join(_lines)
# Telegram 訊息上限 4096 字元
if len(_full) > 4000:
_full = _full[:3950] + "\n… (截斷)"
await self._send_request("sendMessage", {
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
"text": _full,
"parse_mode": "HTML",
"disable_web_page_preview": True,
})
except Exception as _e:
logger.warning("drift_diff_detail_send_failed", report_id=report_id, error=str(_e))
await self._send_request("sendMessage", {
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
"text": f"⚠️ Drift Diff 查詢失敗: <code>{html.escape(str(_e)[:150])}</code>",
"parse_mode": "HTML",
})
async def _edit_drift_card_outcome(
self, report_id: str, verb: str, by: str, ok: bool,
) -> None:
"""
drift_adopt/drift_revert 執行後 edit 原卡片加簽核戳。
"""
try:
_msg_id_raw = await get_redis().get(f"tg_drift:{report_id}")
if not _msg_id_raw:
return
_msg_id = int(_msg_id_raw)
_icon = "" if ok else ""
_stamp = f"\n━━━━━━━━━━━━━━━━━━━\n{_icon} {verb} by @{html.escape(by)} ({'成功' if ok else '失敗'})"
# 先移除按鈕
await self._send_request("editMessageReplyMarkup", {
"chat_id": settings.OPENCLAW_TG_CHAT_ID,
"message_id": _msg_id,
"reply_markup": {"inline_keyboard": []},
})
except Exception as _e:
logger.warning("drift_card_outcome_edit_failed", report_id=report_id, error=str(_e))
# =========================================================================
# ADR-075: TYPE-8M Meta-System 告警(飛輪/告警鏈路健康)
# 2026-04-12 ogt
@@ -2751,6 +2897,21 @@ class TelegramGateway:
if guard_result is not None:
return guard_result
# ===================================================================
# Step 1.85: 2026-04-19 ogt + Claude Opus 4.7 — drift_* 按鈕直接處理
# 修 Telegram 子系統 bug TG-2: drift_view/drift_adopt/drift_revert
# 過去無 handler → 按下永遠「執行中」/ fallthrough 誤觸發 approve
# ===================================================================
if action in ("drift_view", "drift_adopt", "drift_revert"):
return await self._handle_drift_action(
action=action,
approval_id=approval_id, # 本身即 report_id
callback_query_id=callback_query_id,
user_id=user_id,
username=username,
user=user,
)
# ===================================================================
# Step 1.9: Phase 5 Sprint 5.3 — 分類按鈕寫類 action 路由
# 2026-04-14 Claude Sonnet 4.6