""" Discord Webhook Provider ======================== Phase 6: leWOOOgo Output Plugins 精美戰報格式: - Discord Embed 豐富內容 - 狀態顏色標示 - 簽核者、影響範圍完整呈現 """ import httpx from src.core.config import settings from src.core.logging import get_logger from .base import ( ExecutionStatus, NotificationMessage, NotificationProvider, NotificationResult, NotificationStatus, ) logger = get_logger("awoooi.notifications.discord") class DiscordWebhookProvider(NotificationProvider): """ Discord Webhook 通知提供者 使用 Discord Embed 格式發送精美戰報 """ def __init__(self, webhook_url: str | None = None): self._webhook_url = webhook_url or settings.DISCORD_WEBHOOK_URL self._client: httpx.AsyncClient | None = None @property def name(self) -> str: return "discord" @property def enabled(self) -> bool: return bool(self._webhook_url) async def _get_client(self) -> httpx.AsyncClient: """取得 HTTP Client (timeout=5s 防止主執行緒阻塞)""" if self._client is None: self._client = httpx.AsyncClient( timeout=httpx.Timeout(5.0, connect=3.0), # 總超時 5s, 連線 3s ) return self._client def _get_embed_color(self, status: ExecutionStatus) -> int: """取得 Embed 顏色 (Discord 使用十進位整數)""" if status == ExecutionStatus.SUCCESS: return 0x00FF00 # 綠色 elif status == ExecutionStatus.FAILED: return 0xFF0000 # 紅色 elif status == ExecutionStatus.DRY_RUN_BLOCKED: return 0xFFA500 # 橙色 return 0x808080 # 灰色 def _build_embed(self, message: NotificationMessage) -> dict: """ 建構 Discord Embed 精美戰報 格式: ┌────────────────────────────────────────┐ │ ✅ 任務執行成功 │ │ ───────────────────────────────────── │ │ 🎯 動作: 重新啟動 harbor-core │ │ 📋 描述: Pod CrashLoopBackOff 修復 │ │ ───────────────────────────────────── │ │ 👥 簽核者: CTO 林技術長, CISO 陳資安長 │ │ 🔴 風險等級: CRITICAL │ │ ───────────────────────────────────── │ │ 💥 影響範圍 │ │ • 受影響 Pods: 3 │ │ • 預估停機: ~30s │ │ • 相關服務: api, auth │ │ ───────────────────────────────────── │ │ 🤖 AI Provider: Ollama (信心度: 85%) │ │ ⏱️ 執行時間: 234ms │ └────────────────────────────────────────┘ """ # 標題 title = f"{message.status_emoji} {message.status_text}" # 描述 description = f"**{message.action_title}**" if message.action_description: description += f"\n{message.action_description[:200]}" # 簽核者欄位 signers_value = message.signers_display if message.signers: signers_details = [] for s in message.signers: detail = f"• {s.get('name', 'Unknown')}" if s.get("comment"): detail += f" - _{s['comment'][:50]}_" signers_details.append(detail) signers_value = "\n".join(signers_details) # 影響範圍欄位 blast_radius_lines = [ f"• 受影響 Pods: **{message.affected_pods}**", f"• 預估停機: **{message.estimated_downtime}**", f"• 資料影響: **{message.data_impact.upper()}**", ] if message.related_services: services = ", ".join(message.related_services[:5]) blast_radius_lines.append(f"• 相關服務: {services}") # 執行細節 execution_lines = [ f"• 操作類型: **{message.operation_type}**", f"• Namespace: `{message.namespace}`", ] if message.duration_ms: execution_lines.append(f"• 執行時間: **{message.duration_ms}ms**") if message.error_message: execution_lines.append(f"• 錯誤: `{message.error_message[:100]}`") # AI 資訊 ai_lines = [f"• Provider: **{message.ai_provider}**"] if message.confidence: ai_lines.append(f"• 信心度: **{message.confidence:.0%}**") # 建構 Embed embed = { "title": title, "description": description, "color": self._get_embed_color(message.execution_status), "fields": [ { "name": f"👥 簽核者 ({len(message.signers)}/{message.required_signatures})", "value": signers_value or "無", "inline": True, }, { "name": f"{message.risk_emoji} 風險等級", "value": message.risk_level.upper(), "inline": True, }, { "name": "💥 影響範圍 (Blast Radius)", "value": "\n".join(blast_radius_lines), "inline": False, }, { "name": "⚙️ 執行細節", "value": "\n".join(execution_lines), "inline": True, }, { "name": "🤖 AI 分析", "value": "\n".join(ai_lines), "inline": True, }, ], "footer": { "text": f"AWOOOI leWOOOgo Engine | Approval ID: {message.approval_id[:8]}...", "icon_url": "https://cdn.discordapp.com/emojis/1234567890.png", # 可替換 }, "timestamp": message.timestamp.isoformat(), } return embed async def send(self, message: NotificationMessage) -> NotificationResult: """發送 Discord 精美戰報""" if not self.enabled: logger.warning("discord_webhook_disabled", reason="No webhook URL configured") return NotificationResult( status=NotificationStatus.SKIPPED, provider=self.name, message="Discord webhook not configured", ) try: client = await self._get_client() # 建構 Discord Webhook Payload payload = { "username": "AWOOOI OpenClaw", "avatar_url": "https://i.imgur.com/your-avatar.png", # 可替換 "embeds": [self._build_embed(message)], } logger.info( "discord_sending_notification", approval_id=message.approval_id, status=message.execution_status.value, ) # 發送請求 response = await client.post( self._webhook_url, json=payload, ) if response.status_code in (200, 204): logger.info( "discord_notification_sent", approval_id=message.approval_id, status_code=response.status_code, ) return NotificationResult( status=NotificationStatus.SUCCESS, provider=self.name, message="Discord notification sent successfully", response_data={"status_code": response.status_code}, ) else: error_text = response.text[:200] logger.error( "discord_notification_failed", approval_id=message.approval_id, status_code=response.status_code, error=error_text, ) return NotificationResult( status=NotificationStatus.FAILED, provider=self.name, message=f"Discord API error: {response.status_code}", error=error_text, ) except Exception as e: logger.exception( "discord_notification_exception", approval_id=message.approval_id, error=str(e), ) return NotificationResult( status=NotificationStatus.FAILED, provider=self.name, message="Exception occurred", error=str(e), ) async def test_connection(self) -> bool: """測試 Discord Webhook 連線""" if not self.enabled: return False try: client = await self._get_client() # 發送測試訊息 test_payload = { "username": "AWOOOI OpenClaw", "content": "🔔 **AWOOOI 連線測試** - leWOOOgo Notification System 已就緒!", } response = await client.post( self._webhook_url, json=test_payload, ) return response.status_code in (200, 204) except Exception as e: logger.error("discord_connection_test_failed", error=str(e)) return False async def close(self) -> None: """關閉 HTTP client""" if self._client: await self._client.aclose() self._client = None