Files
Aether/_deprecated_py_src/plugins/notification/webhook.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 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)
2026-04-03 16:26:16 +08:00

322 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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