包含依赖配置、gitignore规则、配置Schema与使用文档,支持通过/list、/uptime、/状态、/健康指令查询监控状态,可配置多个监控项目,展示监控项在线状态、延迟及维护和事件公告。
243 lines
9.3 KiB
Python
243 lines
9.3 KiB
Python
import asyncio
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
from astrbot.api import AstrBotConfig, logger
|
|
from astrbot.api.event import AstrMessageEvent, filter
|
|
from astrbot.api.star import Context, Star, register
|
|
|
|
|
|
@register(
|
|
"UptimeKuma状态监控",
|
|
"Henry_Du",
|
|
"通过 /list 指令查询 Uptime Kuma 状态页",
|
|
"2.0.0",
|
|
"https://github.com/HenryDu8133/astrbot_plugin_uptime_kuma_puller",
|
|
)
|
|
class UptimeKumaPuller(Star):
|
|
def __init__(self, context: Context, config: AstrBotConfig):
|
|
super().__init__(context)
|
|
self.query_url = (config.get("query_url") or "").rstrip("/")
|
|
self.project_map = self._build_project_map(config.get("proj_name_list") or [])
|
|
self.timeout_seconds = 15
|
|
self.is_configured = bool(self.query_url and self.project_map)
|
|
|
|
if not self.is_configured:
|
|
logger.warning("请先在管理面板配置 query_url 和 proj_name_list")
|
|
|
|
@filter.command("list", alias={"uptime", "状态", "健康"})
|
|
async def handle_list(self, event: AstrMessageEvent):
|
|
"""查询 Uptime Kuma 状态;支持 /list 或 /list <项目名>。"""
|
|
if not self.is_configured:
|
|
yield event.plain_result(
|
|
"❌插件未配置,请先在管理面板填写 query_url 和 proj_name_list。"
|
|
)
|
|
return
|
|
|
|
raw_arg = (event.message_str or "").strip()
|
|
logger.info("收到 Uptime Kuma 查询请求:%s", raw_arg or "/list")
|
|
|
|
if raw_arg:
|
|
project_key = raw_arg.lower()
|
|
if project_key not in self.project_map:
|
|
project_names = "、".join(self.project_map.values())
|
|
yield event.plain_result(
|
|
f"❌未找到项目:{raw_arg}\n可用项目:{project_names}"
|
|
)
|
|
return
|
|
|
|
result = await self.render_project_status(
|
|
project_key,
|
|
self.project_map[project_key],
|
|
)
|
|
yield event.plain_result(result)
|
|
return
|
|
|
|
messages = []
|
|
for project_key, display_name in self.project_map.items():
|
|
messages.append(await self.render_project_status(project_key, display_name))
|
|
|
|
yield event.plain_result("\n\n====================\n\n".join(messages))
|
|
|
|
def _build_project_map(self, project_names: list[Any]) -> dict[str, str]:
|
|
project_map: dict[str, str] = {}
|
|
for item in project_names:
|
|
name = str(item).strip()
|
|
if not name:
|
|
continue
|
|
project_map[name.lower()] = name
|
|
return project_map
|
|
|
|
async def fetch_json(self, session: aiohttp.ClientSession, url: str) -> dict[str, Any]:
|
|
async with session.get(url) as response:
|
|
if response.status != 200:
|
|
raise RuntimeError(f"HTTP {response.status}: {url}")
|
|
return await response.json(content_type=None)
|
|
|
|
async def fetch_project_payload(
|
|
self, project_key: str
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
|
status_url = f"{self.query_url}/api/status-page/{project_key}"
|
|
heartbeat_url = f"{self.query_url}/api/status-page/heartbeat/{project_key}"
|
|
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
return await asyncio.gather(
|
|
self.fetch_json(session, status_url),
|
|
self.fetch_json(session, heartbeat_url),
|
|
)
|
|
|
|
async def render_project_status(self, project_key: str, display_name: str) -> str:
|
|
try:
|
|
status_data, heartbeat_data = await self.fetch_project_payload(project_key)
|
|
except asyncio.TimeoutError:
|
|
logger.error("获取项目 %s 状态超时", display_name)
|
|
return f"❌ {display_name} 查询超时,请稍后再试。"
|
|
except Exception as exc:
|
|
logger.error("获取项目 %s 状态失败:%s", display_name, exc)
|
|
return f"❌ {display_name} 查询失败:{exc}"
|
|
|
|
project_title = (
|
|
status_data.get("config", {}).get("title")
|
|
or display_name
|
|
)
|
|
monitor_lines, up_count, down_count, unknown_count = self.build_monitor_lines(
|
|
status_data, heartbeat_data
|
|
)
|
|
notice_lines = self.build_notice_lines(status_data)
|
|
|
|
summary = [
|
|
f"📋 {project_title}",
|
|
f"✅ 正常:{up_count} ❌ 异常:{down_count} ❔ 未知:{unknown_count}",
|
|
]
|
|
|
|
if monitor_lines:
|
|
summary.extend(monitor_lines)
|
|
else:
|
|
summary.append("暂无可展示的监控项。")
|
|
|
|
if notice_lines:
|
|
summary.append("==============")
|
|
summary.extend(notice_lines)
|
|
|
|
return "\n".join(summary)
|
|
|
|
def build_monitor_lines(
|
|
self,
|
|
status_data: dict[str, Any],
|
|
heartbeat_data: dict[str, Any],
|
|
) -> tuple[list[str], int, int, int]:
|
|
heartbeat_list = heartbeat_data.get("heartbeatList", {})
|
|
monitor_items: list[tuple[str, str, int]] = []
|
|
up_count = 0
|
|
down_count = 0
|
|
unknown_count = 0
|
|
|
|
for group in status_data.get("publicGroupList", []):
|
|
for monitor in group.get("monitorList", []):
|
|
monitor_id = str(monitor.get("id"))
|
|
monitor_name = self.format_monitor_name(monitor)
|
|
latest_heartbeat = (heartbeat_list.get(monitor_id) or [None])[-1]
|
|
status_text, status_code = self.format_monitor_status(latest_heartbeat)
|
|
|
|
if status_code == 1:
|
|
up_count += 1
|
|
elif status_code == 0:
|
|
down_count += 1
|
|
else:
|
|
unknown_count += 1
|
|
|
|
monitor_items.append((monitor_name, status_text, status_code))
|
|
|
|
monitor_items.sort(key=lambda item: item[0])
|
|
monitor_lines = [f"{name} {status_text}" for name, status_text, _ in monitor_items]
|
|
return monitor_lines, up_count, down_count, unknown_count
|
|
|
|
def format_monitor_name(self, monitor: dict[str, Any]) -> str:
|
|
tags = monitor.get("tags") or []
|
|
if tags:
|
|
first_tag_name = tags[0].get("name")
|
|
if first_tag_name:
|
|
return f"[{first_tag_name}]{monitor.get('name', '未命名监控项')}"
|
|
return str(monitor.get("name", "未命名监控项"))
|
|
|
|
def format_monitor_status(
|
|
self, heartbeat: dict[str, Any] | None
|
|
) -> tuple[str, int]:
|
|
if not heartbeat:
|
|
return "⚪[未知]", -1
|
|
|
|
status = heartbeat.get("status")
|
|
ping = heartbeat.get("ping")
|
|
ping_text = f" {ping}ms" if ping is not None else ""
|
|
|
|
if status == 1:
|
|
return f"🟢[在线]{ping_text}", 1
|
|
if status in (0, 2, 3):
|
|
return f"🔴[离线]{ping_text}", 0
|
|
return f"⚪[未知]{ping_text}", -1
|
|
|
|
def build_notice_lines(self, status_data: dict[str, Any]) -> list[str]:
|
|
lines: list[str] = []
|
|
|
|
for maintenance in status_data.get("maintenanceList", []):
|
|
try:
|
|
title = maintenance.get("title", "无标题")
|
|
content = maintenance.get("description", "无描述")
|
|
start_time = self.format_timestamp(maintenance.get("startDate"))
|
|
end_time = self.format_timestamp(maintenance.get("endDate"))
|
|
lines.append(f"🔵【维护中】{title}")
|
|
lines.append(content)
|
|
if start_time and end_time:
|
|
lines.append(f"⏲️维护时间:{start_time} - {end_time}")
|
|
except Exception as exc:
|
|
logger.error("处理维护消息时出错:%s", exc)
|
|
|
|
incident_list = status_data.get("incidentList", [])
|
|
if not incident_list and status_data.get("incident"):
|
|
incident_list = [status_data["incident"]]
|
|
|
|
style_map = {
|
|
"info": "信息",
|
|
"warning": "警告",
|
|
"danger": "危险",
|
|
"primary": "主要",
|
|
"light": "明亮",
|
|
"dark": "黑暗",
|
|
}
|
|
|
|
for incident in incident_list:
|
|
try:
|
|
style = style_map.get(str(incident.get("style", "info")).lower(), "信息")
|
|
title = incident.get("title", "无标题")
|
|
content = incident.get("content", "无内容")
|
|
last_updated = self.format_datetime_string(incident.get("lastUpdatedDate"))
|
|
lines.append(f"📣【{style}】{title}")
|
|
lines.append(content)
|
|
if last_updated:
|
|
lines.append(f"⏲️本通知更新于:{last_updated}")
|
|
except Exception as exc:
|
|
logger.error("处理事件公告时出错:%s", exc)
|
|
|
|
return lines
|
|
|
|
def format_timestamp(self, value: Any) -> str:
|
|
if value in (None, ""):
|
|
return ""
|
|
try:
|
|
return datetime.fromtimestamp(int(value) / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return str(value)
|
|
|
|
def format_datetime_string(self, value: Any) -> str:
|
|
if value in (None, ""):
|
|
return ""
|
|
if isinstance(value, str):
|
|
normalized = value.replace("T", " ").replace("Z", "")
|
|
try:
|
|
return datetime.fromisoformat(normalized).strftime("%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
return value
|
|
return str(value)
|