this is a push
This commit is contained in:
@@ -11,6 +11,7 @@ import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -20,6 +21,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
ALERT_CLASSES = ("fire", "smoke")
|
||||
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -58,6 +60,18 @@ class AlertManager:
|
||||
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
|
||||
self._states: dict[str, SessionState] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._delivery_stats = {
|
||||
channel: {
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"tests": 0,
|
||||
"last_status": "idle",
|
||||
"last_delivery_at": None,
|
||||
"last_test_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
for channel in CHANNEL_NAMES
|
||||
}
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=4,
|
||||
thread_name_prefix="alert-dispatch",
|
||||
@@ -74,6 +88,54 @@ class AlertManager:
|
||||
def enabled(self) -> bool:
|
||||
return any(self.channels.values())
|
||||
|
||||
|
||||
@property
|
||||
def channel_status(self) -> list[dict[str, Any]]:
|
||||
configured = self.channels
|
||||
with self._lock:
|
||||
stats = {
|
||||
channel: dict(values)
|
||||
for channel, values in self._delivery_stats.items()
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": channel,
|
||||
"name": CHANNEL_NAMES[channel],
|
||||
"configured": configured[channel],
|
||||
"signed": channel == "feishu" and bool(self.feishu_secret),
|
||||
"capabilities": (
|
||||
["Markdown 告警", "检测截图"]
|
||||
if channel == "wechat"
|
||||
else ["交互卡片", "签名校验"]
|
||||
),
|
||||
**stats[channel],
|
||||
}
|
||||
for channel in CHANNEL_NAMES
|
||||
]
|
||||
|
||||
def test_channel(self, channel: str) -> dict[str, Any]:
|
||||
if channel not in CHANNEL_NAMES:
|
||||
raise ValueError(f"Unsupported robot channel: {channel}")
|
||||
if not self.channels[channel]:
|
||||
raise ValueError(f"{CHANNEL_NAMES[channel]}机器人尚未配置")
|
||||
|
||||
try:
|
||||
if channel == "wechat":
|
||||
self._send_wechat_test()
|
||||
else:
|
||||
self._send_feishu_test()
|
||||
except (HTTPError, URLError, TimeoutError, ValueError) as error:
|
||||
self._record_delivery(
|
||||
channel,
|
||||
success=False,
|
||||
error=str(error),
|
||||
test=True,
|
||||
)
|
||||
raise RuntimeError(str(error)) from error
|
||||
self._record_delivery(channel, success=True, test=True)
|
||||
return next(
|
||||
status for status in self.channel_status if status["id"] == channel
|
||||
)
|
||||
def evaluate(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -147,14 +209,88 @@ class AlertManager:
|
||||
if self.wechat_webhook_url:
|
||||
try:
|
||||
self._send_wechat_alert(image, triggered, detections)
|
||||
self._record_delivery("wechat", success=True)
|
||||
except (HTTPError, URLError, TimeoutError, ValueError) as error:
|
||||
self._record_delivery("wechat", success=False, error=str(error))
|
||||
LOGGER.error("WeChat alert failed: %s", error)
|
||||
if self.feishu_webhook_url:
|
||||
try:
|
||||
self._send_feishu_alert(triggered, detections)
|
||||
self._record_delivery("feishu", success=True)
|
||||
except (HTTPError, URLError, TimeoutError, ValueError) as error:
|
||||
self._record_delivery("feishu", success=False, error=str(error))
|
||||
LOGGER.error("Feishu alert failed: %s", error)
|
||||
|
||||
|
||||
def _record_delivery(
|
||||
self,
|
||||
channel: str,
|
||||
*,
|
||||
success: bool,
|
||||
error: str | None = None,
|
||||
test: bool = False,
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self._lock:
|
||||
stats = self._delivery_stats[channel]
|
||||
stats["last_status"] = "success" if success else "failed"
|
||||
stats["last_error"] = None if success else error
|
||||
stats["delivered" if success else "failed"] += 1
|
||||
if test:
|
||||
stats["tests"] += 1
|
||||
stats["last_test_at"] = now
|
||||
else:
|
||||
stats["last_delivery_at"] = now
|
||||
|
||||
def _send_wechat_test(self) -> None:
|
||||
if not self.wechat_webhook_url:
|
||||
return
|
||||
post_json(
|
||||
self.wechat_webhook_url,
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": (
|
||||
"燧安机器人连接测试\n"
|
||||
"> 消息通道工作正常\n"
|
||||
f"> 测试时间:{time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _send_feishu_test(self) -> None:
|
||||
if not self.feishu_webhook_url:
|
||||
return
|
||||
payload: dict[str, Any] = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"template": "green",
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "燧安机器人连接测试",
|
||||
},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": (
|
||||
"**消息通道工作正常**\n"
|
||||
f"测试时间:{time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
if self.feishu_secret:
|
||||
timestamp = str(int(time.time()))
|
||||
payload["timestamp"] = timestamp
|
||||
payload["sign"] = feishu_signature(
|
||||
timestamp,
|
||||
self.feishu_secret,
|
||||
)
|
||||
post_json(self.feishu_webhook_url, payload)
|
||||
def _send_wechat_alert(
|
||||
self,
|
||||
image: Image.Image,
|
||||
|
||||
Reference in New Issue
Block a user