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,
|
||||
|
||||
@@ -44,7 +44,7 @@ app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET", "POST", "DELETE"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -171,6 +171,33 @@ def list_events(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/robots")
|
||||
def list_robots() -> dict[str, Any]:
|
||||
robots = ALERT_MANAGER.channel_status
|
||||
return {
|
||||
"robots": robots,
|
||||
"summary": {
|
||||
"total": len(robots),
|
||||
"configured": sum(robot["configured"] for robot in robots),
|
||||
"delivered": sum(robot["delivered"] for robot in robots),
|
||||
"failed": sum(robot["failed"] for robot in robots),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/robots/{channel}/test")
|
||||
def test_robot(channel: str) -> dict[str, Any]:
|
||||
try:
|
||||
robot = ALERT_MANAGER.test_channel(channel)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"机器人消息发送失败: {error}",
|
||||
) from error
|
||||
return {"status": "sent", "robot": robot}
|
||||
|
||||
@app.patch("/api/events/{event_id}")
|
||||
def update_event(
|
||||
event_id: str,
|
||||
@@ -191,6 +218,7 @@ def dashboard() -> dict[str, Any]:
|
||||
"summary": EVENT_STORE.summary(),
|
||||
"recent_events": EVENT_STORE.list(limit=6),
|
||||
"system": health(),
|
||||
"robots": list_robots(),
|
||||
"detection": {
|
||||
"confidence": CONFIDENCE,
|
||||
"iou": IOU,
|
||||
|
||||
Reference in New Issue
Block a user