feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
24
apps/api/src/services/notifications/__init__.py
Normal file
24
apps/api/src/services/notifications/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
leWOOOgo Notification System
|
||||
=============================
|
||||
Phase 6: Output Plugins 生態系
|
||||
|
||||
NotificationProvider 介面 + 具體實作:
|
||||
- DiscordWebhookProvider
|
||||
- SlackWebhookProvider (TODO)
|
||||
- LineNotifyProvider (TODO)
|
||||
"""
|
||||
|
||||
from .base import NotificationProvider, NotificationMessage, NotificationResult, ExecutionStatus
|
||||
from .discord import DiscordWebhookProvider
|
||||
from .manager import NotificationManager, get_notification_manager
|
||||
|
||||
__all__ = [
|
||||
"NotificationProvider",
|
||||
"NotificationMessage",
|
||||
"NotificationResult",
|
||||
"ExecutionStatus",
|
||||
"DiscordWebhookProvider",
|
||||
"NotificationManager",
|
||||
"get_notification_manager",
|
||||
]
|
||||
163
apps/api/src/services/notifications/base.py
Normal file
163
apps/api/src/services/notifications/base.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Notification Provider Base Interface
|
||||
=====================================
|
||||
Phase 6: leWOOOgo Output Plugins
|
||||
|
||||
設計原則:
|
||||
1. 抽象介面 - 所有 Provider 必須實作 send()
|
||||
2. 統一訊息格式 - NotificationMessage
|
||||
3. 結果追蹤 - NotificationResult
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NotificationStatus(str, Enum):
|
||||
"""通知狀態"""
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class ExecutionStatus(str, Enum):
|
||||
"""執行狀態"""
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
DRY_RUN_BLOCKED = "dry_run_blocked"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationMessage:
|
||||
"""
|
||||
通知訊息統一格式
|
||||
|
||||
所有 Provider 都從這個格式轉換成各自的 API 格式
|
||||
"""
|
||||
# 執行結果
|
||||
execution_status: ExecutionStatus
|
||||
|
||||
# 核心資訊
|
||||
action_title: str
|
||||
action_description: str
|
||||
approval_id: str
|
||||
|
||||
# 簽核資訊
|
||||
signers: list[dict[str, str]] = field(default_factory=list) # [{"name": "CTO", "comment": "..."}]
|
||||
required_signatures: int = 1
|
||||
|
||||
# 影響範圍 (Blast Radius)
|
||||
affected_pods: int = 0
|
||||
estimated_downtime: str = "N/A"
|
||||
related_services: list[str] = field(default_factory=list)
|
||||
data_impact: str = "none"
|
||||
|
||||
# 執行細節
|
||||
namespace: str = "default"
|
||||
operation_type: str = "unknown"
|
||||
duration_ms: int | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
# AI 分析
|
||||
risk_level: str = "medium"
|
||||
ai_provider: str = "unknown"
|
||||
confidence: float | None = None
|
||||
|
||||
# 時間戳
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
"""狀態 Emoji"""
|
||||
if self.execution_status == ExecutionStatus.SUCCESS:
|
||||
return "✅"
|
||||
elif self.execution_status == ExecutionStatus.FAILED:
|
||||
return "❌"
|
||||
elif self.execution_status == ExecutionStatus.DRY_RUN_BLOCKED:
|
||||
return "🛡️"
|
||||
return "⏳"
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
"""狀態文字"""
|
||||
if self.execution_status == ExecutionStatus.SUCCESS:
|
||||
return "任務執行成功"
|
||||
elif self.execution_status == ExecutionStatus.FAILED:
|
||||
return "執行失敗"
|
||||
elif self.execution_status == ExecutionStatus.DRY_RUN_BLOCKED:
|
||||
return "Dry-Run 攔截"
|
||||
return "等待中"
|
||||
|
||||
@property
|
||||
def risk_emoji(self) -> str:
|
||||
"""風險等級 Emoji"""
|
||||
if self.risk_level == "critical":
|
||||
return "🔴"
|
||||
elif self.risk_level == "medium":
|
||||
return "🟡"
|
||||
return "🟢"
|
||||
|
||||
@property
|
||||
def signers_display(self) -> str:
|
||||
"""簽核者顯示文字"""
|
||||
if not self.signers:
|
||||
return "無"
|
||||
return ", ".join([s.get("name", "Unknown") for s in self.signers])
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
"""通知發送結果"""
|
||||
status: NotificationStatus
|
||||
provider: str
|
||||
message: str
|
||||
response_data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class NotificationProvider(ABC):
|
||||
"""
|
||||
通知提供者抽象介面
|
||||
|
||||
所有 Output Plugin 必須實作此介面
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Provider 名稱"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def enabled(self) -> bool:
|
||||
"""是否啟用"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, message: NotificationMessage) -> NotificationResult:
|
||||
"""
|
||||
發送通知
|
||||
|
||||
Args:
|
||||
message: 統一格式的通知訊息
|
||||
|
||||
Returns:
|
||||
NotificationResult: 發送結果
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def test_connection(self) -> bool:
|
||||
"""
|
||||
測試連線
|
||||
|
||||
Returns:
|
||||
bool: 是否連線成功
|
||||
"""
|
||||
pass
|
||||
274
apps/api/src/services/notifications/discord.py
Normal file
274
apps/api/src/services/notifications/discord.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Discord Webhook Provider
|
||||
========================
|
||||
Phase 6: leWOOOgo Output Plugins
|
||||
|
||||
精美戰報格式:
|
||||
- Discord Embed 豐富內容
|
||||
- 狀態顏色標示
|
||||
- 簽核者、影響範圍完整呈現
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.logging import get_logger
|
||||
from .base import (
|
||||
NotificationProvider,
|
||||
NotificationMessage,
|
||||
NotificationResult,
|
||||
NotificationStatus,
|
||||
ExecutionStatus,
|
||||
)
|
||||
|
||||
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 ClawBot",
|
||||
"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 ClawBot",
|
||||
"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
|
||||
169
apps/api/src/services/notifications/manager.py
Normal file
169
apps/api/src/services/notifications/manager.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Notification Manager
|
||||
====================
|
||||
Phase 6: leWOOOgo Output Plugins
|
||||
|
||||
管理所有 NotificationProvider,統一發送介面
|
||||
"""
|
||||
|
||||
from src.core.logging import get_logger
|
||||
from .base import (
|
||||
NotificationProvider,
|
||||
NotificationMessage,
|
||||
NotificationResult,
|
||||
NotificationStatus,
|
||||
)
|
||||
from .discord import DiscordWebhookProvider
|
||||
|
||||
logger = get_logger("awoooi.notifications.manager")
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""
|
||||
通知管理器
|
||||
|
||||
管理多個 NotificationProvider,支援:
|
||||
- 同時發送至多個頻道
|
||||
- 優雅降級 (單一 Provider 失敗不影響其他)
|
||||
- 結果追蹤
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._providers: list[NotificationProvider] = []
|
||||
self._initialized = False
|
||||
|
||||
def register(self, provider: NotificationProvider) -> None:
|
||||
"""註冊 Provider"""
|
||||
if provider.enabled:
|
||||
self._providers.append(provider)
|
||||
logger.info(
|
||||
"notification_provider_registered",
|
||||
provider=provider.name,
|
||||
enabled=provider.enabled,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"notification_provider_disabled",
|
||||
provider=provider.name,
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""初始化所有 Provider"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# 註冊 Discord
|
||||
discord = DiscordWebhookProvider()
|
||||
self.register(discord)
|
||||
|
||||
# TODO: 註冊其他 Provider
|
||||
# slack = SlackWebhookProvider()
|
||||
# self.register(slack)
|
||||
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
"notification_manager_initialized",
|
||||
provider_count=len(self._providers),
|
||||
providers=[p.name for p in self._providers],
|
||||
)
|
||||
|
||||
async def send_all(self, message: NotificationMessage) -> list[NotificationResult]:
|
||||
"""
|
||||
發送通知至所有已註冊的 Provider
|
||||
|
||||
Returns:
|
||||
list[NotificationResult]: 各 Provider 的發送結果
|
||||
"""
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
if not self._providers:
|
||||
logger.warning("no_notification_providers_available")
|
||||
return [
|
||||
NotificationResult(
|
||||
status=NotificationStatus.SKIPPED,
|
||||
provider="none",
|
||||
message="No notification providers configured",
|
||||
)
|
||||
]
|
||||
|
||||
results = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = await provider.send(message)
|
||||
results.append(result)
|
||||
logger.info(
|
||||
"notification_sent",
|
||||
provider=provider.name,
|
||||
status=result.status.value,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"notification_send_failed",
|
||||
provider=provider.name,
|
||||
error=str(e),
|
||||
)
|
||||
results.append(
|
||||
NotificationResult(
|
||||
status=NotificationStatus.FAILED,
|
||||
provider=provider.name,
|
||||
message="Exception during send",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def test_all(self) -> dict[str, bool]:
|
||||
"""
|
||||
測試所有 Provider 連線
|
||||
|
||||
Returns:
|
||||
dict[str, bool]: Provider 名稱 → 連線狀態
|
||||
"""
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
results = {}
|
||||
for provider in self._providers:
|
||||
try:
|
||||
results[provider.name] = await provider.test_connection()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"notification_test_failed",
|
||||
provider=provider.name,
|
||||
error=str(e),
|
||||
)
|
||||
results[provider.name] = False
|
||||
|
||||
return results
|
||||
|
||||
async def close(self) -> None:
|
||||
"""關閉所有 Provider"""
|
||||
for provider in self._providers:
|
||||
if hasattr(provider, "close"):
|
||||
await provider.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton Instance
|
||||
# =============================================================================
|
||||
|
||||
_notification_manager: NotificationManager | None = None
|
||||
|
||||
|
||||
def get_notification_manager() -> NotificationManager:
|
||||
"""取得 NotificationManager 單例"""
|
||||
global _notification_manager
|
||||
if _notification_manager is None:
|
||||
_notification_manager = NotificationManager()
|
||||
_notification_manager.initialize()
|
||||
return _notification_manager
|
||||
|
||||
|
||||
async def close_notification_manager() -> None:
|
||||
"""關閉 NotificationManager"""
|
||||
global _notification_manager
|
||||
if _notification_manager:
|
||||
await _notification_manager.close()
|
||||
_notification_manager = None
|
||||
Reference in New Issue
Block a user