mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""
|
||
Webhook通知插件
|
||
通过HTTP Webhook发送通知
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import time
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
if TYPE_CHECKING:
|
||
import aiohttp
|
||
|
||
try:
|
||
import aiohttp
|
||
|
||
AIOHTTP_AVAILABLE = True
|
||
except ImportError:
|
||
AIOHTTP_AVAILABLE = False
|
||
|
||
from src.core.logger import logger
|
||
|
||
from .base import Notification, NotificationLevel, NotificationPlugin
|
||
|
||
|
||
class WebhookNotificationPlugin(NotificationPlugin):
|
||
"""
|
||
Webhook通知插件
|
||
支持多种Webhook格式(Slack, Discord, 通用)
|
||
"""
|
||
|
||
def __init__(self, name: str = "webhook", config: dict[str, Any] | None = None):
|
||
super().__init__(name, config)
|
||
|
||
if not AIOHTTP_AVAILABLE:
|
||
self.enabled = False
|
||
logger.warning("aiohttp not installed, webhook plugin disabled")
|
||
return
|
||
|
||
# Webhook配置
|
||
self.webhook_url = config.get("webhook_url") if config else None
|
||
self.webhook_type = (
|
||
config.get("webhook_type", "generic") if config else "generic"
|
||
) # generic, slack, discord, teams
|
||
self.secret = config.get("secret") if config else None # 用于签名
|
||
self.timeout = config.get("timeout", 30) if config else 30
|
||
self.headers = config.get("headers", {}) if config else {}
|
||
|
||
# 缓冲配置
|
||
self._buffer: list[Notification] = []
|
||
self._buffer_max_size = config.get("buffer_max_size", 500) if config else 500
|
||
self._lock = asyncio.Lock()
|
||
self._session: aiohttp.ClientSession | None = None
|
||
self._flush_task = None
|
||
|
||
if not self.webhook_url:
|
||
self.enabled = False
|
||
logger.warning("No webhook URL configured")
|
||
return
|
||
|
||
# 启动刷新任务
|
||
self._start_flush_task()
|
||
|
||
def _start_flush_task(self) -> None:
|
||
"""启动定时刷新任务"""
|
||
|
||
async def flush_loop() -> Any:
|
||
while self.enabled:
|
||
await asyncio.sleep(self.flush_interval)
|
||
await self.flush()
|
||
|
||
self._flush_task = asyncio.create_task(flush_loop())
|
||
|
||
async def _get_session(self) -> aiohttp.ClientSession:
|
||
"""获取HTTP会话"""
|
||
if not self._session:
|
||
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout))
|
||
return self._session
|
||
|
||
def _generate_signature(self, payload: str) -> str:
|
||
"""生成请求签名"""
|
||
if not self.secret:
|
||
return ""
|
||
|
||
# 使用HMAC-SHA256生成签名
|
||
signature = hmac.new(
|
||
self.secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
||
).hexdigest()
|
||
|
||
return signature
|
||
|
||
def _format_for_slack(self, notification: Notification) -> dict[str, Any]:
|
||
"""格式化为Slack消息"""
|
||
# Slack颜色映射
|
||
color_map = {
|
||
NotificationLevel.INFO: "#36a64f",
|
||
NotificationLevel.WARNING: "warning",
|
||
NotificationLevel.ERROR: "danger",
|
||
NotificationLevel.CRITICAL: "#ff0000",
|
||
}
|
||
|
||
return {
|
||
"text": notification.title,
|
||
"attachments": [
|
||
{
|
||
"color": color_map.get(notification.level, "#808080"),
|
||
"title": notification.title,
|
||
"text": notification.message,
|
||
"fields": (
|
||
[
|
||
{"title": k, "value": str(v), "short": True}
|
||
for k, v in notification.metadata.items()
|
||
]
|
||
if notification.metadata
|
||
else []
|
||
),
|
||
"footer": notification.source,
|
||
"ts": int(notification.timestamp.timestamp()),
|
||
}
|
||
],
|
||
}
|
||
|
||
def _format_for_discord(self, notification: Notification) -> dict[str, Any]:
|
||
"""格式化为Discord消息"""
|
||
# Discord颜色映射
|
||
color_map = {
|
||
NotificationLevel.INFO: 0x00FF00,
|
||
NotificationLevel.WARNING: 0xFFA500,
|
||
NotificationLevel.ERROR: 0xFF0000,
|
||
NotificationLevel.CRITICAL: 0x8B0000,
|
||
}
|
||
|
||
embeds = [
|
||
{
|
||
"title": notification.title,
|
||
"description": notification.message,
|
||
"color": color_map.get(notification.level, 0x808080),
|
||
"fields": (
|
||
[
|
||
{"name": k, "value": str(v), "inline": True}
|
||
for k, v in notification.metadata.items()
|
||
]
|
||
if notification.metadata
|
||
else []
|
||
),
|
||
"footer": {"text": notification.source},
|
||
"timestamp": notification.timestamp.isoformat(),
|
||
}
|
||
]
|
||
|
||
return {"embeds": embeds}
|
||
|
||
def _format_for_teams(self, notification: Notification) -> dict[str, Any]:
|
||
"""格式化为Microsoft Teams消息"""
|
||
# Teams颜色映射
|
||
color_map = {
|
||
NotificationLevel.INFO: "00ff00",
|
||
NotificationLevel.WARNING: "ffa500",
|
||
NotificationLevel.ERROR: "ff0000",
|
||
NotificationLevel.CRITICAL: "8b0000",
|
||
}
|
||
|
||
facts = (
|
||
[{"name": k, "value": str(v)} for k, v in notification.metadata.items()]
|
||
if notification.metadata
|
||
else []
|
||
)
|
||
|
||
return {
|
||
"@type": "MessageCard",
|
||
"@context": "https://schema.org/extensions",
|
||
"themeColor": color_map.get(notification.level, "808080"),
|
||
"title": notification.title,
|
||
"text": notification.message,
|
||
"sections": [{"facts": facts}] if facts else [],
|
||
"summary": notification.title,
|
||
}
|
||
|
||
def _format_payload(self, notification: Notification) -> dict[str, Any]:
|
||
"""根据Webhook类型格式化负载"""
|
||
if self.webhook_type == "slack":
|
||
return self._format_for_slack(notification)
|
||
elif self.webhook_type == "discord":
|
||
return self._format_for_discord(notification)
|
||
elif self.webhook_type == "teams":
|
||
return self._format_for_teams(notification)
|
||
else:
|
||
# 通用格式
|
||
return notification.to_dict()
|
||
|
||
async def _do_send(self, notification: Notification) -> bool:
|
||
"""
|
||
实际发送单个通知
|
||
|
||
Note: 对于 CRITICAL 级别通知,直接发送;其他级别加入缓冲区
|
||
"""
|
||
# 添加到缓冲区
|
||
async with self._lock:
|
||
# 缓冲区溢出保护:丢弃最旧的通知
|
||
if len(self._buffer) >= self._buffer_max_size:
|
||
drop_count = len(self._buffer) - self._buffer_max_size + 1
|
||
del self._buffer[:drop_count]
|
||
logger.warning("Webhook 通知缓冲区溢出,丢弃 {} 条旧通知", drop_count)
|
||
|
||
self._buffer.append(notification)
|
||
|
||
# 如果是严重通知,立即发送
|
||
if notification.level == NotificationLevel.CRITICAL:
|
||
return await self._flush_buffer()
|
||
|
||
# 如果缓冲区满,自动刷新
|
||
if len(self._buffer) >= self.batch_size:
|
||
return await self._flush_buffer()
|
||
|
||
return True
|
||
|
||
async def _do_send_batch(self, notifications: list[Notification]) -> dict[str, Any]:
|
||
"""实际批量发送通知"""
|
||
success_count = 0
|
||
failed_count = 0
|
||
errors = []
|
||
|
||
if not notifications:
|
||
return {"total": 0, "sent": 0, "failed": 0}
|
||
|
||
# 批量发送
|
||
for notification in notifications:
|
||
try:
|
||
payload = self._format_payload(notification)
|
||
payload_str = json.dumps(payload)
|
||
|
||
headers = dict(self.headers)
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
# 添加签名
|
||
if self.secret:
|
||
signature = self._generate_signature(payload_str)
|
||
headers["X-Signature"] = signature
|
||
headers["X-Timestamp"] = str(int(time.time()))
|
||
|
||
# 发送请求
|
||
session = await self._get_session()
|
||
async with session.post(
|
||
self.webhook_url, data=payload_str, headers=headers
|
||
) as response:
|
||
if response.status < 300:
|
||
success_count += 1
|
||
else:
|
||
failed_count += 1
|
||
error_text = await response.text()
|
||
errors.append(f"HTTP {response.status}: {error_text}")
|
||
|
||
except Exception as e:
|
||
failed_count += 1
|
||
errors.append(str(e))
|
||
|
||
return {
|
||
"total": len(notifications),
|
||
"sent": success_count,
|
||
"failed": failed_count,
|
||
"errors": errors,
|
||
}
|
||
|
||
async def _flush_buffer(self) -> bool:
|
||
"""刷新缓冲的通知(内部方法,不带锁)"""
|
||
if not self._buffer:
|
||
return True
|
||
|
||
notifications = self._buffer[:]
|
||
self._buffer.clear()
|
||
|
||
# 批量发送(直接调用 _do_send_batch 避免重复统计)
|
||
result = await self._do_send_batch(notifications)
|
||
return result["failed"] == 0
|
||
|
||
async def flush(self) -> bool:
|
||
"""刷新缓冲的通知"""
|
||
async with self._lock:
|
||
return await self._flush_buffer()
|
||
|
||
async def _get_extra_stats(self) -> dict[str, Any]:
|
||
"""获取 Webhook 特定的统计信息"""
|
||
return {
|
||
"type": "webhook",
|
||
"webhook_type": self.webhook_type,
|
||
"webhook_url": (
|
||
self.webhook_url.split("?")[0] if self.webhook_url else None
|
||
), # 隐藏查询参数
|
||
"buffer_size": len(self._buffer),
|
||
"has_secret": bool(self.secret),
|
||
}
|
||
|
||
async def _do_shutdown(self) -> None:
|
||
"""清理资源"""
|
||
await self.close()
|
||
|
||
async def close(self) -> Any:
|
||
"""关闭插件"""
|
||
# 刷新缓冲
|
||
await self.flush()
|
||
|
||
# 取消刷新任务
|
||
if self._flush_task:
|
||
self._flush_task.cancel()
|
||
|
||
# 关闭HTTP会话
|
||
if self._session:
|
||
await self._session.close()
|
||
|
||
def __del__(self) -> None:
|
||
"""清理资源"""
|
||
try:
|
||
from src.utils.async_utils import safe_create_task
|
||
|
||
safe_create_task(self.close())
|
||
except Exception:
|
||
pass
|