fix: Telegram 簽核 gate + 執行結果 reply — 打通人工審核閉環
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m7s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m7s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"✅ <b>執行成功</b>\n<code>{(approval.action or '')[:180]}</code>"
|
||||
else:
|
||||
err_short = (error or "未知錯誤")[:150]
|
||||
text = f"❌ <b>執行失敗</b>\n<code>{(approval.action or '')[:180]}</code>\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。
|
||||
|
||||
@@ -4769,7 +4769,9 @@ class TelegramGateway:
|
||||
if action == "approve":
|
||||
status_emoji = "✅"
|
||||
status_text = f"<b>已批准</b> 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"<b>已拒絕</b> 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(
|
||||
|
||||
Reference in New Issue
Block a user