From 72dd0c5875127a1c9d04b1dddfdddc7f29ee00fe Mon Sep 17 00:00:00 2001 From: OG T Date: Tue, 14 Apr 2026 19:03:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Telegram=20=E7=B0=BD=E6=A0=B8=20gate=20+?= =?UTF-8?q?=20=E5=9F=B7=E8=A1=8C=E7=B5=90=E6=9E=9C=20reply=20=E2=80=94=20?= =?UTF-8?q?=E6=89=93=E9=80=9A=E4=BA=BA=E5=B7=A5=E5=AF=A9=E6=A0=B8=E9=96=89?= =?UTF-8?q?=E7=92=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 處修復(統帥盤查發現): 1. telegram_gateway.py:4890 — gate 從 execution_triggered 改 approval.status==APPROVED - 原 gate 靠樂觀鎖旗標,race 時失效(REST+Telegram 同時簽核) - 與 REST API approvals.py:360 路徑對齊 - 加 Redis lock exec:{approval_id} 60s TTL 防重入 2. telegram_gateway.py:4772 — 拿掉「👀 等待執行」誤導文案 - 批准後一律顯示「⚡ 執行中...」,實際結果由 #3 reply 補上 3. approval_execution.py — 新增 _push_execution_result_to_alert() - 成功/失敗兩處 fire-and-forget 呼叫 - requested_by=="auto_approve" skip(避免與 _push_auto_repair_result 衝突) - Redis tg_msg:{incident_id} 查原告警 message_id → reply_to - 找不到 message_id 靜默不發,不影響執行主流程 防破壞性檢查: - ✅ 自動執行路徑不受影響(skip via requested_by) - ✅ Reject 路徑完全不動 - ✅ Redis lock 防重入 - ✅ 132 回歸測試全過 Co-Authored-By: Claude Haiku 4.5 --- apps/api/src/services/approval_execution.py | 86 +++++++++++++++++++++ apps/api/src/services/telegram_gateway.py | 48 ++++++++---- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index aa97417d..13320a9b 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -250,6 +250,12 @@ class ApprovalExecutionService: ) ) + # 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示執行結果 + # auto_approve 路徑由 _push_auto_repair_result 處理,此處僅處理人工批准 + asyncio.create_task( + self._push_execution_result_to_alert(approval, success=True, error=None) + ) + # Phase 7.6: 觸發 Playbook 自動萃取 (fire-and-forget) asyncio.create_task( self._trigger_playbook_extraction(approval) @@ -325,6 +331,13 @@ class ApprovalExecutionService: ) ) + # 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示失敗結果 + asyncio.create_task( + self._push_execution_result_to_alert( + approval, success=False, error=result.error + ) + ) + # ADR-030 Phase 5: 觸發學習服務 (失敗案例) asyncio.create_task( self._trigger_learning( @@ -335,6 +348,79 @@ class ApprovalExecutionService: ) ) + async def _push_execution_result_to_alert( + self, + approval: ApprovalRequest, + success: bool, + error: str | None, + ) -> None: + """ + 執行結果回覆到原告警 Telegram 卡片(reply_to_message_id) + + 2026-04-14 Claude Sonnet 4.6 實裝: + - 人工路徑:人類在 Telegram 點批准後,等執行完成,在原告警下 reply 執行結果 + - 自動路徑 (requested_by=auto_approve) 由 _push_auto_repair_result 處理,此處 skip + + 透過 Redis tg_msg:{incident_id} 查原告警 message_id,找不到則靜默不發。 + """ + try: + # 自動執行路徑 skip(避免與 _push_auto_repair_result 重複發訊息) + if (approval.requested_by or "").lower() == "auto_approve": + return + + if not approval.incident_id: + return + + from src.core.redis_client import get_redis + redis = get_redis() + msg_id_raw = await redis.get(f"tg_msg:{approval.incident_id}") + if not msg_id_raw: + logger.debug( + "push_execution_result_no_msg_id", + incident_id=approval.incident_id, + approval_id=str(approval.id), + ) + return + + try: + orig_msg_id = int(msg_id_raw) + except (TypeError, ValueError): + return + + from src.core.config import get_settings + from src.services.telegram_gateway import get_telegram_gateway + settings = get_settings() + gateway = get_telegram_gateway() + + if success: + text = f"✅ 執行成功\n{(approval.action or '')[:180]}" + else: + err_short = (error or "未知錯誤")[:150] + text = f"❌ 執行失敗\n{(approval.action or '')[:180]}\n原因: {err_short}" + + await gateway._http_client.post( + f"https://api.telegram.org/bot{settings.OPENCLAW_TG_BOT_TOKEN}/sendMessage", + json={ + "chat_id": settings.OPENCLAW_TG_CHAT_ID, + "text": text, + "parse_mode": "HTML", + "reply_to_message_id": orig_msg_id, + }, + ) + logger.info( + "push_execution_result_sent", + incident_id=approval.incident_id, + approval_id=str(approval.id), + success=success, + orig_msg_id=orig_msg_id, + ) + except Exception as e: + logger.warning( + "push_execution_result_failed", + approval_id=str(approval.id), + error=str(e), + ) + async def _get_anomaly_key_from_approval(self, approval: ApprovalRequest) -> str | None: """ 從 approval → incident → anomaly_key。 diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index d987e926..e63820ca 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -4769,7 +4769,9 @@ class TelegramGateway: if action == "approve": status_emoji = "✅" status_text = f"已批准 by {_html.escape(username)}" - suffix = "⚡ 執行中..." if execution_triggered else "👀 等待執行" + # 2026-04-14 Claude Sonnet 4.6: 原「等待執行」誤導(實際沒有 gate 會卡住路徑) + # 批准後一律顯示「執行中」,真實結果由 _push_execution_result_to_alert reply 補上 + suffix = "⚡ 執行中..." else: status_emoji = "❌" status_text = f"已拒絕 by {_html.escape(username)}" @@ -4885,20 +4887,40 @@ class TelegramGateway: username=username, execution_triggered=execution_triggered, ) - # ADR-073 修補: 觸發實際執行 (之前 sign_approval 只更新 DB 狀態,指令從未執行) - # execution_triggered=True 代表簽名數已達 required_signatures - if execution_triggered: + # ADR-073 修補 + 2026-04-14 Claude Sonnet 4.6 修復: + # 原本 gate 用 execution_triggered,race condition 時失效(樂觀鎖失敗) + # 改用 approval.status == APPROVED(與 REST API 路徑 approvals.py:360 對齊) + # 用 Redis lock exec:{approval_id} 防重入(REST + Telegram 同時簽核) + from src.models.approval import ApprovalStatus + if approval.status == ApprovalStatus.APPROVED: import asyncio + + from src.core.redis_client import get_redis from src.services.approval_execution import get_execution_service - _exec_task = asyncio.create_task( - get_execution_service().execute_approved_action(approval) - ) - _exec_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) - logger.info( - "telegram_approval_execution_triggered", - approval_id=approval_id, - action=approval.action, - ) + + _redis = get_redis() + _lock_key = f"exec:{approval.id}" + # SET NX EX 60 — 60s 內同一 approval 只能執行一次 + _acquired = await _redis.set(_lock_key, "1", nx=True, ex=60) + if _acquired: + _exec_task = asyncio.create_task( + get_execution_service().execute_approved_action(approval) + ) + _exec_task.add_done_callback( + lambda t: t.exception() if not t.cancelled() else None + ) + logger.info( + "telegram_approval_execution_triggered", + approval_id=approval_id, + action=approval.action, + gate="status=APPROVED", + ) + else: + logger.info( + "telegram_approval_execution_skipped_lock_held", + approval_id=approval_id, + reason="另一路徑 (REST/自動) 已取得 exec lock", + ) elif action == "reject": approval, message = await service.reject_approval(