From abc21c735ee23254a08b8aaeadf742f7c601d269 Mon Sep 17 00:00:00 2001 From: OG T Date: Fri, 27 Mar 2026 09:50:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20P1=20Telegram=20=E6=8C=89=E9=88=95?= =?UTF-8?q?=E5=84=AA=E5=8C=96=20-=20=E7=A8=8D=E5=BE=8C/=E9=9D=9C=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增按鈕: - ⏰ 稍後 (snooze): 延遲 30 分鐘後再提醒 - 🔕 靜默 1h (silence): 同類資源告警靜默 1 小時 實作細節: - telegram_gateway.py: 新增 _handle_snooze/_handle_silence - decision_manager.py: 發送前檢查 silence 狀態 - Redis Key: telegram_snooze:{approval_id}, telegram_silence:{resource_name} - Skill 03 v1.5 → v1.6 Co-Authored-By: Claude Opus 4.5 --- .../skills/03-openclaw-cognitive-expert.md | 22 +- apps/api/src/services/decision_manager.py | 12 + apps/api/src/services/telegram_gateway.py | 213 +++++++++++++++++- 3 files changed, 237 insertions(+), 10 deletions(-) diff --git a/.agents/skills/03-openclaw-cognitive-expert.md b/.agents/skills/03-openclaw-cognitive-expert.md index 4f211b078..c27109de0 100644 --- a/.agents/skills/03-openclaw-cognitive-expert.md +++ b/.agents/skills/03-openclaw-cognitive-expert.md @@ -10,10 +10,10 @@ | 欄位 | 值 | |------|-----| -| **版本** | v1.5 | +| **版本** | v1.6 | | **建立日期** | 2026-03-20 (台北) | | **建立者** | Claude Code | -| **最後修改** | 2026-03-27 09:45 (台北) | +| **最後修改** | 2026-03-27 15:30 (台北) | | **修改者** | Claude Code (首席架構師) | ### 變更紀錄 @@ -26,6 +26,7 @@ | v1.3 | 2026-03-25 | Claude Code | 加入文件資訊區塊 | | v1.4 | 2026-03-26 | Claude Code | K8s 資源名稱驗證 (ADR-016) | | v1.5 | 2026-03-27 | Claude Code | Stream Key 統一 + 告警去重機制 | +| v1.6 | 2026-03-27 | Claude Code | **P1 優化: 稍後/靜默按鈕** | --- @@ -59,6 +60,23 @@ TTL: 600 秒 (10 分鐘) 用途: 防止相同 Incident 重複發送 Telegram ``` +### 5. Telegram 按鈕佈局 (2026-03-27 v1.6) +``` +第一行 (主要操作): +[✅ 簽核] [❌ 拒絕] + +第二行 (延遲/靜默): +[⏰ 稍後] [🔕 靜默 1h] + +第三行 (可選): +[⚡ 執行自動調優] +``` + +| 按鈕 | 功能 | Redis Key | TTL | +|------|------|-----------|-----| +| ⏰ 稍後 | 30 分鐘後再提醒 | `telegram_snooze:{approval_id}` | 30 分鐘 | +| 🔕 靜默 1h | 同類資源告警靜默 | `telegram_silence:{resource_name}` | 1 小時 | + --- ## 核心約束 (Cognitive Iron Laws) diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index 2d54dcd9f..5165da244 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -73,6 +73,18 @@ async def _push_decision_to_telegram( ) return + # 🔴 靜默檢查:此資源是否被靜默 (2026-03-27 P1 優化) + target = incident.affected_services[0] if incident.affected_services else "unknown" + silence_key = f"telegram_silence:{target}" + if await redis.exists(silence_key): + logger.info( + "telegram_push_silenced", + reason="Resource is silenced", + incident_id=incident.incident_id, + resource=target, + ) + return + # 檢查是否有設定 Bot Token if not settings.OPENCLAW_TG_BOT_TOKEN: logger.debug( diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index c356cf5a1..90c9d40d6 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -31,12 +31,21 @@ import httpx import structlog from src.core.config import settings +from src.core.redis_client import get_redis from src.services.security_interceptor import ( NonceReplayError, UserNotWhitelistedError, get_security_interceptor, ) +# ============================================================================= +# Snooze/Silence Redis Keys (2026-03-27 P1 優化) +# ============================================================================= +SNOOZE_KEY_PREFIX = "telegram_snooze:" # {approval_id} -> 稍後提醒 +SILENCE_KEY_PREFIX = "telegram_silence:" # {resource_name} -> 靜默 +SNOOZE_TTL_SECONDS = 30 * 60 # 30 分鐘 +SILENCE_TTL_SECONDS = 60 * 60 # 1 小時 + logger = structlog.get_logger(__name__) @@ -331,11 +340,14 @@ class TelegramGateway: auto_tuning_command: str = "", ) -> dict: """ - 建立 Inline Keyboard (簽核按鈕 + 自動調優) + 建立 Inline Keyboard (簽核按鈕 + 延遲/靜默 + 自動調優) - SOUL.md 規範 + v7.0: - [✅ 簽核] [❌ 拒絕] - [⚡ 執行自動調優] + 2026-03-27 ogt: P1 優化 - 新增稍後/靜默按鈕 + + 佈局: + 第一行: [✅ 簽核] [❌ 拒絕] + 第二行: [⏰ 稍後] [🔕 靜默 1h] + 第三行: [⚡ 執行自動調優] (可選) Args: approval_id: 簽核單 ID @@ -348,8 +360,10 @@ class TelegramGateway: # 產生 Nonce (防重放) approve_nonce = self._security.generate_callback_nonce(approval_id, "approve") reject_nonce = self._security.generate_callback_nonce(approval_id, "reject") + snooze_nonce = self._security.generate_callback_nonce(approval_id, "snooze") + silence_nonce = self._security.generate_callback_nonce(approval_id, "silence") - # 基本按鈕行 + # 第一行: 主要操作 buttons = [ [ { @@ -360,10 +374,21 @@ class TelegramGateway: "text": "❌ 拒絕", "callback_data": reject_nonce, }, - ] + ], + # 第二行: 延遲/靜默 (2026-03-27 P1 優化) + [ + { + "text": "⏰ 稍後", + "callback_data": snooze_nonce, + }, + { + "text": "🔕 靜默 1h", + "callback_data": silence_nonce, + }, + ], ] - # 自動調優按鈕 (v7.0) + # 第三行: 自動調優按鈕 (v7.0) if include_auto_tuning and auto_tuning_command: tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune") buttons.append([ @@ -560,6 +585,59 @@ class TelegramGateway: "auto_tuning_result": auto_tuning_result, } + # =================================================================== + # Step 2.5: 處理稍後/靜默 (2026-03-27 P1 優化) + # =================================================================== + if action == "snooze": + snooze_result = await self._handle_snooze( + approval_id=approval_id, + username=username, + ) + await self._answer_callback( + callback_query_id, + "snooze", + text="⏰ 30 分鐘後再提醒", + ) + await self._update_message_after_action( + message_id=message_id, + action="snooze", + username=username, + original_text=original_text, + ) + return { + "action": action, + "approval_id": approval_id, + "user": user, + "success": True, + "snooze_result": snooze_result, + } + + if action == "silence": + silence_result = await self._handle_silence( + approval_id=approval_id, + username=username, + original_text=original_text, + ) + await self._answer_callback( + callback_query_id, + "silence", + text="🔕 此類告警靜默 1 小時", + ) + await self._update_message_after_action( + message_id=message_id, + action="silence", + username=username, + original_text=original_text, + extra_info=silence_result.get("resource_name", ""), + ) + return { + "action": action, + "approval_id": approval_id, + "user": user, + "success": True, + "silence_result": silence_result, + } + # =================================================================== # Step 3: 回應 Callback Query (簽核/拒絕) # =================================================================== @@ -686,6 +764,116 @@ class TelegramGateway: "error": str(e), } + async def _handle_snooze( + self, + approval_id: str, + username: str, + ) -> dict: + """ + 處理稍後提醒 (2026-03-27 P1 優化) + + 功能: 延遲 30 分鐘後再提醒此告警 + + Args: + approval_id: 簽核單 ID + username: 執行者名稱 + + Returns: + dict: 處理結果 + """ + try: + redis = get_redis() + snooze_key = f"{SNOOZE_KEY_PREFIX}{approval_id}" + + # 設置 30 分鐘延遲標記 + await redis.setex( + snooze_key, + SNOOZE_TTL_SECONDS, + f"{username}:{datetime.now(UTC).isoformat()}", + ) + + logger.info( + "telegram_snooze_set", + approval_id=approval_id, + username=username, + ttl_minutes=SNOOZE_TTL_SECONDS // 60, + ) + + return { + "snoozed": True, + "approval_id": approval_id, + "snooze_until": datetime.now(UTC).isoformat(), + "ttl_minutes": SNOOZE_TTL_SECONDS // 60, + } + + except Exception as e: + logger.error("snooze_error", error=str(e), approval_id=approval_id) + return { + "snoozed": False, + "error": str(e), + } + + async def _handle_silence( + self, + approval_id: str, + username: str, + original_text: str, + ) -> dict: + """ + 處理靜默 1 小時 (2026-03-27 P1 優化) + + 功能: 同類告警 (相同資源) 1 小時內不再發送 + + Args: + approval_id: 簽核單 ID + username: 執行者名稱 + original_text: 原始訊息 (用於解析資源名稱) + + Returns: + dict: 處理結果 + """ + try: + redis = get_redis() + + # 從原始訊息解析資源名稱 (格式: 🎯 資源: xxx) + resource_name = "unknown" + for line in original_text.split("\n"): + if "🎯 資源:" in line or "🎯 資源: " in line: + resource_name = line.split(":")[-1].strip() + break + + silence_key = f"{SILENCE_KEY_PREFIX}{resource_name}" + + # 設置 1 小時靜默標記 + await redis.setex( + silence_key, + SILENCE_TTL_SECONDS, + f"{username}:{datetime.now(UTC).isoformat()}:{approval_id}", + ) + + logger.info( + "telegram_silence_set", + approval_id=approval_id, + resource_name=resource_name, + username=username, + ttl_hours=SILENCE_TTL_SECONDS // 3600, + ) + + return { + "silenced": True, + "approval_id": approval_id, + "resource_name": resource_name, + "silence_until": datetime.now(UTC).isoformat(), + "ttl_hours": SILENCE_TTL_SECONDS // 3600, + } + + except Exception as e: + logger.error("silence_error", error=str(e), approval_id=approval_id) + return { + "silenced": False, + "error": str(e), + } + async def _answer_callback( self, callback_query_id: str, @@ -700,6 +888,10 @@ class TelegramGateway: text = "❌ 已拒絕" elif action == "tune": text = "⚡ 調優中..." + elif action == "snooze": + text = "⏰ 30 分鐘後再提醒" + elif action == "silence": + text = "🔕 此類告警靜默 1 小時" else: text = "✓ 已處理" @@ -725,7 +917,7 @@ class TelegramGateway: - 必須在底部加上分隔線與簽核狀態 - 移除所有按鈕 """ - # 構建鋼印 + # 構建鋼印 (2026-03-27 ogt: 新增 snooze/silence) if action == "approve": stamp = f"✅ 已由 @{username} 授權執行" elif action == "reject": @@ -734,6 +926,11 @@ class TelegramGateway: stamp = f"⚡ 已由 @{username} 觸發自動調優 (Shadow Mode)" if extra_info: stamp += "\n📝 指令已記錄" + elif action == "snooze": + stamp = f"⏰ @{username} 已設定 30 分鐘後再提醒" + elif action == "silence": + resource_info = f" ({extra_info})" if extra_info else "" + stamp = f"🔕 @{username} 已靜默此類告警 1 小時{resource_info}" else: stamp = f"✓ 已由 @{username} 處理"