- 刪除舊版 clawbot.py (已有新版 openclaw.py) - 更新 models/ai.py 類型定義 (ClawBotAnalysisRequest/Response) - 更新 api/v1/ai.py import 與註解 - 更新 Discord username - 更新所有註解與文檔 依據: feedback_openclaw_naming.md (統帥 2026-03-20 正式命名決議) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
275 lines
9.9 KiB
Python
275 lines
9.9 KiB
Python
"""
|
|
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
|