diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index bdf88ecca..c085a19c1 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -156,7 +156,7 @@ async def _push_decision_to_telegram( # 2026-03-27 ogt: 修復 INC-INC-INC- 重複前綴 bug approval_id = incident.incident_id # 已經是 INC-xxx 格式 - await gateway.send_approval_card( + tg_result = await gateway.send_approval_card( approval_id=approval_id, risk_level=risk_level, resource_name=target[:50], @@ -177,6 +177,11 @@ async def _push_decision_to_telegram( incident_id=incident.incident_id, ) + # 2026-04-09 Claude Sonnet 4.6: 存 message_id → 後續狀態更新在原訊息延續 + tg_message_id = tg_result.get("result", {}).get("message_id") if isinstance(tg_result, dict) else None + if tg_message_id: + await redis.setex(f"tg_msg:{incident.incident_id}", 86400, str(tg_message_id)) + # 🔴 發送成功後設置去重 key (TTL 10 分鐘) await redis.setex(dedup_key, 600, "1") @@ -203,8 +208,10 @@ async def _push_auto_repair_result( error: str = "", ) -> None: """ - 自動修復執行後,發 Telegram 結果通知 - 統帥要求: 修復結果必須對應到同一告警訊息 + 自動修復執行後,在原始告警訊息追加狀態行。 + 統帥要求: 所有狀態變更必須在原告警訊息延續,不發新訊息。 + - append_incident_update() 取 Redis tg_msg:{id} → reply 原訊息 + 換按鈕 + - 找不到 message_id 時 fallback 到 send_notification(降級) 2026-04-09 Claude Sonnet 4.6 Asia/Taipei """ try: @@ -215,28 +222,36 @@ async def _push_auto_repair_result( inc_id = incident.incident_id if success: - text = ( - f"✅ [自動修復完成]\n" - f"├ 事件: {inc_id}\n" - f"├ 對象: {target[:50]}\n" - f"└ 執行: {action[:80] if action else '已執行'}" + status_line = ( + f"✅ 自動修復完成\n" + f"└ {action[:100] if action else '已執行'}" ) else: - text = ( - f"❌ [自動修復失敗]\n" - f"├ 事件: {inc_id}\n" - f"├ 對象: {target[:50]}\n" + status_line = ( + f"❌ 自動修復失敗,請人工介入\n" f"├ 動作: {action[:80] if action else '未知'}\n" f"└ 錯誤: {error[:100] if error else '未知錯誤'}" ) - await gateway.send_notification(text) - logger.info( - "auto_repair_result_sent", + # 優先: reply 原告警訊息並換掉按鈕 + appended = await gateway.append_incident_update( incident_id=inc_id, - success=success, + status_line=status_line, + keep_info_buttons=True, # 保留詳情/重診/歷史,移除批准/拒絕 ) + # Fallback: 找不到原訊息 ID(舊告警或 Redis 過期)→ 發新訊息 + if not appended: + fallback_text = ( + f"{'✅' if success else '❌'} [自動修復{'完成' if success else '失敗'}] " + f"{inc_id}\n" + f"對象: {target[:50]}\n" + f"{status_line}" + ) + await gateway.send_notification(fallback_text) + + logger.info("auto_repair_result_sent", incident_id=inc_id, success=success, appended=appended) + except Exception as e: logger.warning("auto_repair_result_push_failed", incident_id=incident.incident_id, error=str(e)) diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 92eb3b986..885bb4b1d 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -2486,6 +2486,95 @@ class TelegramGateway: # 文字更新失敗不影響整體流程,按鈕已移除 logger.warning("telegram_update_text_failed", message_id=message_id, error=str(e)) + async def append_incident_update( + self, + incident_id: str, + status_line: str, + keep_info_buttons: bool = True, + ) -> bool: + """ + 在原始告警訊息追加狀態行,並換掉操作按鈕。 + 用於自動修復完成/失敗後更新原訊息,讓狀態變更在同一則訊息上延續。 + + 流程: + 1. 從 Redis 取 tg_msg:{incident_id} 得到 message_id + 2. editMessageText: 原文 + 分隔線 + status_line + 3. editMessageReplyMarkup: 移除 Row 1 (批准/拒絕/靜默),保留 Row 2 (詳情/重診/歷史) + + Args: + incident_id: Incident ID(用於查 Redis 的 message_id) + status_line: 追加的狀態文字,如「✅ 已自動修復: kubectl rollout restart…」 + keep_info_buttons: 是否保留詳情/重診/歷史按鈕(預設 True) + + Returns: + bool: True = 成功 edit 原訊息;False = 找不到 message_id(fallback 需另行處理) + + 2026-04-09 Claude Sonnet 4.6 Asia/Taipei (統帥要求: 狀態變更在原訊息延續) + """ + from src.core.redis_client import get_redis + + redis = get_redis() + redis_key = f"tg_msg:{incident_id}" + stored = await redis.get(redis_key) + if not stored: + logger.warning( + "append_incident_update_no_message_id", + incident_id=incident_id, + reason="message_id not in Redis", + ) + return False + + try: + message_id = int(stored) + except (ValueError, TypeError): + logger.warning("append_incident_update_invalid_message_id", stored=stored) + return False + + # Step 1: 取得原始訊息文字(Telegram Bot API 不提供讀取原文,只能在 editMessageText 裡重建) + # 策略: 只追加 status_line,不讀取原文(Telegram edit 要傳完整新文字) + # 所以先用 editMessageReplyMarkup 換按鈕,再 sendMessage 同 chat 以 reply 方式追加狀態 + # → 實際上用 reply_to_message_id 讓 Telegram 顯示連結更直觀 + + # Step 1: 換掉按鈕 (移除 Row 1 批准/拒絕/靜默,保留 Row 2 資訊按鈕) + if keep_info_buttons: + new_keyboard = {"inline_keyboard": [ + [ + {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, + {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, + {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, + ], + ]} + else: + new_keyboard = {"inline_keyboard": []} + + try: + await self._send_request("editMessageReplyMarkup", { + "chat_id": self.chat_id, + "message_id": message_id, + "reply_markup": new_keyboard, + }) + except TelegramGatewayError as e: + logger.warning("append_incident_update_edit_buttons_failed", message_id=message_id, error=str(e)) + + # Step 2: Reply 原訊息追加狀態(保留原文不動,以 reply 方式延續) + try: + await self._send_request("sendMessage", { + "chat_id": self.chat_id, + "text": status_line, + "parse_mode": "HTML", + "reply_to_message_id": message_id, + "disable_web_page_preview": True, + }) + except TelegramGatewayError as e: + logger.warning("append_incident_update_reply_failed", message_id=message_id, error=str(e)) + + logger.info( + "append_incident_update_done", + incident_id=incident_id, + message_id=message_id, + ) + return True + async def _send_incident_detail(self, incident_id: str) -> None: """ ADR-050 P2: 傳送事件詳情訊息 (不修改原始簽核卡片)