From 877c8479e085250adaa37396d7dec624dcc50f9a Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 19 Apr 2026 01:06:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(telegram):=20TG-2=20+=20TG-4=20=E4=BF=AE=20?= =?UTF-8?q?drift=20=E6=8C=89=E9=88=95=20black=20hole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/services/telegram_gateway.py | 163 +++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index e8d8013e..2bddd73a 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -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 {html.escape(report_id)}", + "parse_mode": "HTML", + }) + return + + _lines = [f"📊 完整 Drift Diff{html.escape(report_id)}"] + _lines.append(f"Namespace: {html.escape(_rpt.namespace)}") + _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} {html.escape(_field)}") + _lines.append(f" Git: {html.escape(_git)}") + _lines.append(f" K8s: {html.escape(_k8s)}") + 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 查詢失敗: {html.escape(str(_e)[:150])}", + "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