Files
uptime_kuma_puller/main.py
T
Henry_Du d1b49503aa feat(uptime_kuma_puller): 初始化Uptime Kuma状态监控AstrBot插件
包含依赖配置、gitignore规则、配置Schema与使用文档,支持通过/list、/uptime、/状态、/健康指令查询监控状态,可配置多个监控项目,展示监控项在线状态、延迟及维护和事件公告。
2026-07-17 05:28:08 +08:00

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)