Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
912 lines
33 KiB
Python
912 lines
33 KiB
Python
# for_admin_komari_agent.py
|
||
import asyncio
|
||
import json
|
||
import os
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
import aiohttp
|
||
|
||
KOMARI_API_BASE = os.environ.get("KOMARI_API_BASE", "https://status.rrrosmontis.icu").rstrip("/")
|
||
# Komari 已启用 SSO 并关闭密码登录(POST /api/login 返回 403 "Password login is disabled"),
|
||
# 程序化访问改用服务端全局 API Key(Authorization: Bearer),配置见 komari/setting.json -> api_key。
|
||
# 用户名/密码仅在旧版(未关闭密码登录)时作为回退手段保留。
|
||
KOMARI_API_KEY = ""
|
||
KOMARI_USERNAME = "RRRosmontis"
|
||
KOMARI_PASSWORD = os.environ.get('KOMARI_PASSWORD', '')
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
SETTING_FILE = os.path.join(BASE_DIR, "komari", "setting.json")
|
||
|
||
# 服务器离线判定:recent 中最新数据点距今超过该秒数即视为离线
|
||
OFFLINE_AFTER_SECONDS = 180
|
||
|
||
POLLING_ENABLED = True
|
||
POLLING_MINUTES = 5
|
||
# 启动宽限(秒):首次服务器状态检查前先等 TG 通道 register / NapCat 反向连接完成,
|
||
# 否则首次离线告警会投递到空通道,且 _last_state 会照常推进导致该告警永久丢失。
|
||
MONITOR_STARTUP_DELAY = 30
|
||
|
||
websocket = None
|
||
_last_state = {}
|
||
|
||
|
||
def _load_setting():
|
||
try:
|
||
with open(SETTING_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError):
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
return data
|
||
|
||
|
||
def _save_setting(data):
|
||
os.makedirs(os.path.dirname(SETTING_FILE), exist_ok=True)
|
||
with open(SETTING_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def _persist():
|
||
data = _load_setting()
|
||
data.update({
|
||
"polling_enabled": POLLING_ENABLED,
|
||
"polling": POLLING_MINUTES,
|
||
})
|
||
_save_setting(data)
|
||
|
||
|
||
_s = _load_setting()
|
||
POLLING_ENABLED = bool(_s.get("polling_enabled", POLLING_ENABLED))
|
||
POLLING_MINUTES = int(_s.get("polling", POLLING_MINUTES))
|
||
KOMARI_API_KEY = str(_s.get("api_key", "") or "").strip()
|
||
|
||
|
||
def _fmt_bytes(n):
|
||
try:
|
||
n = float(n)
|
||
except (TypeError, ValueError):
|
||
return "?"
|
||
if n < 1024:
|
||
return f"{n:.0f}B"
|
||
for unit in ("K", "M", "G", "T", "P"):
|
||
n /= 1024.0
|
||
if n < 1024:
|
||
return f"{n:.1f}{unit}"
|
||
return f"{n:.1f}E"
|
||
|
||
|
||
def _fmt_uptime(sec):
|
||
try:
|
||
sec = int(sec)
|
||
except (TypeError, ValueError):
|
||
return "?"
|
||
if sec < 0:
|
||
return "?"
|
||
days, rem = divmod(sec, 86400)
|
||
hours, rem = divmod(rem, 3600)
|
||
mins = rem // 60
|
||
if days > 0:
|
||
return f"{days}天{hours}小时{mins}分"
|
||
if hours > 0:
|
||
return f"{hours}小时{mins}分"
|
||
return f"{mins}分钟"
|
||
|
||
|
||
def _fmt_pct(used, total):
|
||
try:
|
||
used = float(used)
|
||
total = float(total)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if total <= 0:
|
||
return None
|
||
return used / total * 100
|
||
|
||
|
||
def _bj_now():
|
||
return datetime.now(timezone(timedelta(hours=8)))
|
||
|
||
|
||
def _parse_updated_at(value):
|
||
if not value:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
return dt
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
async def _reply(websocket, msg_type, target_id, content):
|
||
"""websocket 为 Channel 对象(通道抽象)"""
|
||
from channel import send_message
|
||
await send_message(websocket, msg_type, target_id, content)
|
||
|
||
|
||
API_KEY_TOKEN = "api_key" # API Key 模式下 _login 的返回值(无需真实登录)
|
||
|
||
|
||
async def _login():
|
||
"""返回供 _get_json 使用的鉴权标识。
|
||
|
||
配置了 api_key 时直接返回 API_KEY_TOKEN(请求走 Authorization: Bearer),
|
||
否则回退到旧版密码登录换取 session_token。
|
||
"""
|
||
if KOMARI_API_KEY:
|
||
return API_KEY_TOKEN
|
||
if not KOMARI_PASSWORD:
|
||
return None
|
||
url = f"{KOMARI_API_BASE}/api/login"
|
||
payload = {"username": KOMARI_USERNAME, "password": KOMARI_PASSWORD}
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(url, json=payload, timeout=15) as resp:
|
||
data = await resp.json(content_type=None)
|
||
except Exception as exc:
|
||
print(f"[DEBUG][komari] 密码登录请求异常: {exc}")
|
||
return None
|
||
if data.get("status") != "success":
|
||
message = str(data.get("message") or "")
|
||
if "disabled" in message.lower():
|
||
print("[DEBUG][komari] Komari 已关闭密码登录,请在 komari/setting.json 配置 api_key")
|
||
return None
|
||
set_cookie = (data.get("data") or {}).get("set-cookie") or {}
|
||
return set_cookie.get("session_token")
|
||
|
||
|
||
def _auth_headers(token=None):
|
||
"""API Key 优先(Bearer),否则回退 session_token Cookie"""
|
||
if KOMARI_API_KEY:
|
||
return {"Authorization": f"Bearer {KOMARI_API_KEY}"}
|
||
if token and token != API_KEY_TOKEN:
|
||
return {"Cookie": f"session_token={token}"}
|
||
return {}
|
||
|
||
|
||
async def _get_json(session, path, token=None):
|
||
url = f"{KOMARI_API_BASE}{path}"
|
||
async with session.get(url, headers=_auth_headers(token), timeout=15) as resp:
|
||
return await resp.json(content_type=None)
|
||
|
||
|
||
def _pick_latest(records):
|
||
if not records:
|
||
return None
|
||
latest = records[0]
|
||
for rec in records[1:]:
|
||
if (rec.get("updated_at") or "") > (latest.get("updated_at") or ""):
|
||
latest = rec
|
||
return latest
|
||
|
||
|
||
def _is_offline(latest):
|
||
if not latest:
|
||
return True
|
||
updated_at = _parse_updated_at(latest.get("updated_at"))
|
||
if updated_at is None:
|
||
return True
|
||
age = (_bj_now() - updated_at).total_seconds()
|
||
return age > OFFLINE_AFTER_SECONDS
|
||
|
||
|
||
def _node_name(node):
|
||
return str(node.get("name") or "未命名").strip().strip('"')
|
||
|
||
|
||
def _fmt_node(node, latest, duration=None):
|
||
name = _node_name(node)
|
||
region = str(node.get("region") or "")
|
||
header = f"【{name}】{region}" if region else f"【{name}】"
|
||
if duration and duration > 1:
|
||
header += f"({duration}秒均值)"
|
||
lines = [header]
|
||
|
||
if not latest:
|
||
lines.append(" 状态: 离线 / 暂无数据")
|
||
return "\n".join(lines)
|
||
|
||
cpu = (latest.get("cpu") or {}).get("usage")
|
||
ram = latest.get("ram") or {}
|
||
swap = latest.get("swap") or {}
|
||
disk = latest.get("disk") or {}
|
||
net = latest.get("network") or {}
|
||
uptime = latest.get("uptime")
|
||
updated_at = latest.get("updated_at") or ""
|
||
|
||
parts = []
|
||
if cpu is not None:
|
||
parts.append(f"CPU {cpu:.1f}%")
|
||
ram_used = ram.get("used")
|
||
ram_total = ram.get("total")
|
||
ram_pct = _fmt_pct(ram_used, ram_total)
|
||
if ram_used is not None and ram_total is not None:
|
||
s = f"内存 {_fmt_bytes(ram_used)}/{_fmt_bytes(ram_total)}"
|
||
if ram_pct is not None:
|
||
s += f" ({ram_pct:.1f}%)"
|
||
parts.append(s)
|
||
disk_used = disk.get("used")
|
||
disk_total = disk.get("total")
|
||
disk_pct = _fmt_pct(disk_used, disk_total)
|
||
if disk_used is not None and disk_total is not None:
|
||
s = f"磁盘 {_fmt_bytes(disk_used)}/{_fmt_bytes(disk_total)}"
|
||
if disk_pct is not None:
|
||
s += f" ({disk_pct:.1f}%)"
|
||
parts.append(s)
|
||
swap_used = swap.get("used")
|
||
swap_total = swap.get("total")
|
||
if swap_used is not None and swap_total is not None and swap_total > 0:
|
||
swap_pct = _fmt_pct(swap_used, swap_total)
|
||
s = f"Swap {_fmt_bytes(swap_used)}/{_fmt_bytes(swap_total)}"
|
||
if swap_pct is not None:
|
||
s += f" ({swap_pct:.1f}%)"
|
||
parts.append(s)
|
||
if parts:
|
||
lines.append(" " + " | ".join(parts))
|
||
|
||
up = net.get("up")
|
||
down = net.get("down")
|
||
if up is not None and down is not None:
|
||
lines.append(f" 网络 ↑{_fmt_bytes(up)}/s ↓{_fmt_bytes(down)}/s")
|
||
|
||
extra = []
|
||
if uptime is not None:
|
||
extra.append(f"在线 {_fmt_uptime(uptime)}")
|
||
if updated_at:
|
||
dt = _parse_updated_at(updated_at)
|
||
if dt is not None:
|
||
bj = dt.astimezone(timezone(timedelta(hours=8)))
|
||
extra.append(f"更新 {bj.strftime('%H:%M:%S')}")
|
||
else:
|
||
extra.append(f"更新 {updated_at}")
|
||
if extra:
|
||
lines.append(" " + " | ".join(extra))
|
||
return "\n".join(lines)
|
||
|
||
|
||
STATUS_DURATION_DEFAULT = 1
|
||
STATUS_DURATION_MAX = 30
|
||
|
||
|
||
def _parse_status_args(args):
|
||
"""解析 .server status [秒] [序号|all],返回 (duration, index, error)。"""
|
||
duration = STATUS_DURATION_DEFAULT
|
||
index = "all"
|
||
|
||
if not args:
|
||
return duration, index, None
|
||
|
||
if len(args) > 2:
|
||
return None, None, "参数过多,用法: .server status [秒] [序号|all]"
|
||
|
||
first = args[0].strip()
|
||
if not first.isdigit():
|
||
return None, None, "第一个参数必须是秒数(1-30 的整数),例如 .server status 10 all"
|
||
duration = int(first)
|
||
if duration < 1 or duration > STATUS_DURATION_MAX:
|
||
return None, None, f"统计时长必须在 1-{STATUS_DURATION_MAX} 秒之间"
|
||
|
||
if len(args) == 2:
|
||
second = args[1].strip().lower()
|
||
if second == "all":
|
||
index = "all"
|
||
elif second.isdigit():
|
||
index = int(second)
|
||
if index < 1:
|
||
return None, None, "服务器序号必须从 1 开始"
|
||
else:
|
||
return None, None, "第二个参数只能是 all 或服务器序号(从 1 开始)"
|
||
|
||
return duration, index, None
|
||
|
||
|
||
def _sample_get(latest, *keys):
|
||
v = latest
|
||
for k in keys:
|
||
if not isinstance(v, dict):
|
||
return None
|
||
v = v.get(k)
|
||
return _num(v)
|
||
|
||
|
||
def _avg_of(samples, *keys):
|
||
vals = [v for v in (_sample_get(s, *keys) for s in samples) if v is not None]
|
||
if not vals:
|
||
return None
|
||
return sum(vals) / len(vals)
|
||
|
||
|
||
def _build_avg_latest(samples, node):
|
||
"""把多个采样点合并成 _fmt_node 能用的单条嵌套结构。"""
|
||
if not samples:
|
||
return None
|
||
|
||
last = samples[-1] or {}
|
||
ram_total = _sample_get(last, "ram", "total")
|
||
if not ram_total:
|
||
ram_total = _num(node.get("mem_total"))
|
||
swap_total = _sample_get(last, "swap", "total")
|
||
if not swap_total:
|
||
swap_total = _num(node.get("swap_total"))
|
||
disk_total = _sample_get(last, "disk", "total")
|
||
if not disk_total:
|
||
disk_total = _num(node.get("disk_total"))
|
||
|
||
cpu_avg = _avg_of(samples, "cpu", "usage")
|
||
ram_used_avg = _avg_of(samples, "ram", "used")
|
||
swap_used_avg = _avg_of(samples, "swap", "used")
|
||
disk_used_avg = _avg_of(samples, "disk", "used")
|
||
net_up_avg = _avg_of(samples, "network", "up")
|
||
net_down_avg = _avg_of(samples, "network", "down")
|
||
tcp_avg = _avg_of(samples, "connections", "tcp")
|
||
udp_avg = _avg_of(samples, "connections", "udp")
|
||
|
||
return {
|
||
"cpu": {"usage": cpu_avg},
|
||
"ram": {"used": ram_used_avg, "total": ram_total},
|
||
"swap": {"used": swap_used_avg, "total": swap_total},
|
||
"disk": {"used": disk_used_avg, "total": disk_total},
|
||
"network": {"up": net_up_avg, "down": net_down_avg},
|
||
"connections": {"tcp": tcp_avg, "udp": udp_avg},
|
||
"uptime": _sample_get(last, "uptime"),
|
||
"updated_at": last.get("updated_at") or "",
|
||
}
|
||
|
||
|
||
async def _status_task(websocket, msg_type, target_id, duration, index):
|
||
await _reply(websocket, msg_type, target_id, f"正在统计最近 {duration} 秒服务器状态({'全部' if index == 'all' else f'第 {index} 台'})...")
|
||
try:
|
||
token = await _login()
|
||
if not token:
|
||
await _reply(websocket, msg_type, target_id, "Komari 鉴权失败:请检查 komari/setting.json 的 api_key 配置。")
|
||
return
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
nodes_data = await _get_json(session, "/api/nodes", token)
|
||
if nodes_data.get("status") != "success":
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"获取 Komari 节点列表失败: {nodes_data.get('message') or '未知错误'}",
|
||
)
|
||
return
|
||
nodes = nodes_data.get("data") or []
|
||
if not nodes:
|
||
await _reply(websocket, msg_type, target_id, "Komari 中还没有接入任何服务器节点。")
|
||
return
|
||
|
||
if index == "all":
|
||
selected = list(nodes)
|
||
else:
|
||
if index > len(nodes):
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"服务器序号超出范围,当前共 {len(nodes)} 台(序号从 1 开始)",
|
||
)
|
||
return
|
||
selected = [nodes[index - 1]]
|
||
|
||
# 连续采样 duration 秒(每秒取一次最新点)
|
||
samples = {n.get("uuid"): [] for n in selected}
|
||
loop = asyncio.get_running_loop()
|
||
end = loop.time() + duration
|
||
while True:
|
||
for node in selected:
|
||
uuid = node.get("uuid")
|
||
if not uuid:
|
||
continue
|
||
recent_data = await _get_json(session, f"/api/recent/{uuid}", token)
|
||
latest = _pick_latest(recent_data.get("data") or [])
|
||
samples[uuid].append(latest)
|
||
remaining = end - loop.time()
|
||
if remaining <= 0:
|
||
break
|
||
await asyncio.sleep(min(1.0, remaining))
|
||
|
||
lines = ["Komari 服务器状态", "━━━━━━━━━━━━"]
|
||
for node in selected:
|
||
uuid = node.get("uuid")
|
||
if not uuid:
|
||
continue
|
||
avg_latest = _build_avg_latest(samples.get(uuid, []), node)
|
||
lines.append(_fmt_node(node, avg_latest, duration))
|
||
msg = "\n".join(lines)
|
||
|
||
await _reply(websocket, msg_type, target_id, msg)
|
||
except Exception as exc:
|
||
await _reply(websocket, msg_type, target_id, f"查询 Komari 状态失败: {exc}")
|
||
|
||
|
||
LOAD_TYPES = ("cpu", "ram", "swap", "load", "temp", "disk", "network", "process", "connections", "all")
|
||
|
||
|
||
def _fmt_dt_short(value):
|
||
dt = _parse_updated_at(value)
|
||
if dt is None:
|
||
return str(value or "?")
|
||
bj = dt.astimezone(timezone(timedelta(hours=8)))
|
||
return bj.strftime("%m-%d %H:%M")
|
||
|
||
|
||
def _fmt_date(value):
|
||
if not value:
|
||
return None
|
||
dt = _parse_updated_at(value)
|
||
if dt is None:
|
||
return str(value)[:10]
|
||
return dt.astimezone(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
|
||
|
||
|
||
def _num(v):
|
||
try:
|
||
if v is None:
|
||
return None
|
||
f = float(v)
|
||
if f != f:
|
||
return None
|
||
return f
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _agg(values):
|
||
nums = [x for x in (_num(v) for v in values) if x is not None]
|
||
if not nums:
|
||
return None
|
||
return {
|
||
"count": len(nums),
|
||
"min": min(nums),
|
||
"max": max(nums),
|
||
"avg": sum(nums) / len(nums),
|
||
"last": nums[-1],
|
||
}
|
||
|
||
|
||
def _fmt_node_list(node):
|
||
name = _node_name(node)
|
||
region = str(node.get("region") or "")
|
||
header = f"【{name}】{region}" if region else f"【{name}】"
|
||
lines = [header]
|
||
|
||
os_name = str(node.get("os") or "?")
|
||
arch = str(node.get("arch") or "")
|
||
if arch:
|
||
os_name += f" ({arch})"
|
||
cpu_name = str(node.get("cpu_name") or "?")
|
||
cpu_cores = node.get("cpu_cores")
|
||
if cpu_cores is not None:
|
||
cpu_name += f" {cpu_cores} 核"
|
||
mem_total = _fmt_bytes(node.get("mem_total"))
|
||
disk_total = _fmt_bytes(node.get("disk_total"))
|
||
lines.append(f" {os_name} | {cpu_name}")
|
||
lines.append(f" 内存 {mem_total} | 磁盘 {disk_total}")
|
||
|
||
expired = _fmt_date(node.get("expired_at"))
|
||
if expired:
|
||
lines.append(f" 到期 {expired}")
|
||
|
||
tags = str(node.get("tags") or "")
|
||
remark = str(node.get("public_remark") or "")
|
||
extras = []
|
||
if tags:
|
||
extras.append(f"标签 {tags}")
|
||
if remark:
|
||
extras.append(f"备注 {remark}")
|
||
if extras:
|
||
lines.append(" " + " | ".join(extras))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _fmt_record_summary(records, load_type, node=None):
|
||
if not records:
|
||
return " 无历史记录"
|
||
|
||
times = [r.get("time") for r in records]
|
||
lines = [f" 记录 {len(records)} 条 | {_fmt_dt_short(times[0])} ~ {_fmt_dt_short(times[-1])}"]
|
||
|
||
if load_type in ("cpu", "all"):
|
||
a = _agg([r.get("cpu") for r in records])
|
||
if a:
|
||
lines.append(f" CPU: 均 {a['avg']:.1f}% | 峰 {a['max']:.1f}% | 最新 {a['last']:.1f}%")
|
||
|
||
if load_type in ("ram", "all"):
|
||
a = _agg([r.get("ram") for r in records])
|
||
if a:
|
||
s = f" 内存: 均 {_fmt_bytes(a['avg'])} | 峰 {_fmt_bytes(a['max'])}"
|
||
last_total = _num(records[-1].get("ram_total"))
|
||
if not last_total and node is not None:
|
||
last_total = _num(node.get("mem_total"))
|
||
if last_total and last_total > 0:
|
||
s += f" | 最新 {_fmt_bytes(a['last'])}/{_fmt_bytes(last_total)} ({a['last'] / last_total * 100:.1f}%)"
|
||
lines.append(s)
|
||
|
||
if load_type in ("swap", "all"):
|
||
a = _agg([r.get("swap") for r in records])
|
||
if a:
|
||
s = f" Swap: 均 {_fmt_bytes(a['avg'])} | 峰 {_fmt_bytes(a['max'])}"
|
||
last_total = _num(records[-1].get("swap_total"))
|
||
if not last_total and node is not None:
|
||
last_total = _num(node.get("swap_total"))
|
||
if last_total and last_total > 0:
|
||
s += f" | 最新 {_fmt_bytes(a['last'])}/{_fmt_bytes(last_total)} ({a['last'] / last_total * 100:.1f}%)"
|
||
lines.append(s)
|
||
|
||
if load_type in ("disk", "all"):
|
||
a = _agg([r.get("disk") for r in records])
|
||
if a:
|
||
s = f" 磁盘: 均 {_fmt_bytes(a['avg'])} | 峰 {_fmt_bytes(a['max'])}"
|
||
last_total = _num(records[-1].get("disk_total"))
|
||
if not last_total and node is not None:
|
||
last_total = _num(node.get("disk_total"))
|
||
if last_total and last_total > 0:
|
||
s += f" | 最新 {_fmt_bytes(a['last'])}/{_fmt_bytes(last_total)} ({a['last'] / last_total * 100:.1f}%)"
|
||
lines.append(s)
|
||
|
||
if load_type in ("load", "all"):
|
||
a = _agg([r.get("load") for r in records])
|
||
if a:
|
||
lines.append(f" 负载: 均 {a['avg']:.2f} | 峰 {a['max']:.2f} | 最新 {a['last']:.2f}")
|
||
|
||
if load_type in ("temp", "all"):
|
||
a = _agg([r.get("temp") for r in records])
|
||
if a:
|
||
lines.append(f" 温度: 均 {a['avg']:.1f}℃ | 峰 {a['max']:.1f}℃ | 最新 {a['last']:.1f}℃")
|
||
|
||
if load_type in ("network", "all"):
|
||
in_a = _agg([r.get("net_in") for r in records])
|
||
out_a = _agg([r.get("net_out") for r in records])
|
||
if in_a and out_a:
|
||
lines.append(
|
||
f" 网络: 均 ↓{_fmt_bytes(in_a['avg'])}/s ↑{_fmt_bytes(out_a['avg'])}/s | "
|
||
f"峰 ↓{_fmt_bytes(in_a['max'])}/s ↑{_fmt_bytes(out_a['max'])}/s"
|
||
)
|
||
total_up = _num(records[-1].get("net_total_up"))
|
||
total_down = _num(records[-1].get("net_total_down"))
|
||
if total_up is not None and total_down is not None:
|
||
lines.append(f" 累计: ↑{_fmt_bytes(total_up)} ↓{_fmt_bytes(total_down)}")
|
||
|
||
if load_type in ("process", "all"):
|
||
a = _agg([r.get("process") for r in records])
|
||
if a:
|
||
lines.append(f" 进程: 均 {a['avg']:.0f} | 峰 {a['max']:.0f} | 最新 {a['last']:.0f}")
|
||
|
||
if load_type in ("connections", "all"):
|
||
a = _agg([r.get("connections") for r in records])
|
||
u = _agg([r.get("connections_udp") for r in records])
|
||
if a:
|
||
s = f" 连接: 均 {a['avg']:.0f} | 峰 {a['max']:.0f} | 最新 {a['last']:.0f}"
|
||
if u:
|
||
s += f" (UDP {u['last']:.0f})"
|
||
lines.append(s)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _resolve_node(nodes, query):
|
||
q = query.lower().strip().strip('"')
|
||
if not q:
|
||
return None, None, []
|
||
for n in nodes:
|
||
if _node_name(n).lower() == q:
|
||
return n, _node_name(n), []
|
||
matches = [n for n in nodes if q in _node_name(n).lower()]
|
||
if len(matches) == 1:
|
||
return matches[0], _node_name(matches[0]), []
|
||
return None, None, matches
|
||
|
||
|
||
def _parse_load_args(args):
|
||
hours = 4
|
||
load_type = "all"
|
||
name_parts = []
|
||
for tok in args:
|
||
tl = tok.lower()
|
||
if tl in LOAD_TYPES:
|
||
load_type = tl
|
||
elif tok.isdigit():
|
||
hours = int(tok)
|
||
if hours < 1:
|
||
hours = 1
|
||
if hours > 720:
|
||
hours = 720
|
||
else:
|
||
name_parts.append(tok)
|
||
return " ".join(name_parts), hours, load_type
|
||
|
||
|
||
async def _notify_admins(content, node_uuid="", server="", status=""):
|
||
"""把 Komari 状态变化写入持久通知队列,由各目标的模式决定是否润色。
|
||
|
||
返回 bool: 是否至少有一个有效目标成功入队。入队后由 NotificationCenter
|
||
独立重试,因此调用方可以推进 _last_state;没有目标时不入队、不推进。
|
||
"""
|
||
import time
|
||
import identity
|
||
import notifications
|
||
|
||
queued = 0
|
||
for uid in identity.admin_uids():
|
||
try:
|
||
targets = identity.push_targets(uid)
|
||
if not targets:
|
||
continue
|
||
event = notifications.Event(
|
||
kind="komari",
|
||
dedupe_key=f"komari:{node_uuid}:{status}:{uid}:{time.time_ns()}",
|
||
legacy_text=content,
|
||
uid=uid,
|
||
payload={
|
||
"server": server,
|
||
"status": status,
|
||
"source": "Komari",
|
||
},
|
||
)
|
||
notifications.center.publish(event)
|
||
queued += len(targets)
|
||
except Exception as exc:
|
||
print(f"[DEBUG][komari] 通知管理员身份 {uid} 入队失败: {exc}")
|
||
if queued <= 0:
|
||
print("[DEBUG][komari] 本轮告警没有任何有效目标入队")
|
||
return queued > 0
|
||
|
||
|
||
async def _check_servers_once():
|
||
global _last_state
|
||
if not POLLING_ENABLED:
|
||
return
|
||
|
||
token = await _login()
|
||
if not token:
|
||
print("[DEBUG][komari] 轮询鉴权失败(请检查 komari/setting.json 的 api_key),跳过本轮")
|
||
return
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
nodes_data = await _get_json(session, "/api/nodes", token)
|
||
if nodes_data.get("status") != "success":
|
||
print(f"[DEBUG][komari] 轮询获取节点列表失败: {nodes_data.get('message')}")
|
||
return
|
||
|
||
nodes = nodes_data.get("data") or []
|
||
current = {}
|
||
unreadable = set()
|
||
for node in nodes:
|
||
uuid = node.get("uuid")
|
||
if not uuid:
|
||
continue
|
||
try:
|
||
recent_data = await _get_json(session, f"/api/recent/{uuid}", token)
|
||
latest = _pick_latest(recent_data.get("data") or [])
|
||
except Exception as exc:
|
||
print(f"[DEBUG][komari] 轮询节点 {uuid} 状态异常: {exc}")
|
||
# A failed read is unknown, not evidence that the node is offline.
|
||
# Keep its previous state and omit it from this round's transitions.
|
||
unreadable.add(uuid)
|
||
continue
|
||
offline = _is_offline(latest)
|
||
current[uuid] = {
|
||
"name": _node_name(node),
|
||
"offline": offline,
|
||
"latest": latest,
|
||
}
|
||
|
||
# 报告首次发现的离线节点 + 状态变化。
|
||
# 状态推进与投递绑定:某节点本轮需要发告警时,仅当该告警确有目标投递成功
|
||
# 才推进 _last_state[uuid];投递全失败则保留旧状态,下一轮重新判定并重发
|
||
# (幂等重发优于永久丢失)。告警文案一律用当前真实状态,绝不发陈旧反向告警。
|
||
for uuid, info in current.items():
|
||
prev = _last_state.get(uuid)
|
||
notice = None
|
||
desc = ""
|
||
if prev is None:
|
||
if info["offline"]:
|
||
notice = f"[Komari] 服务器「{info['name']}」当前处于离线状态。"
|
||
desc = "首次检查发现离线"
|
||
elif prev["offline"] and not info["offline"]:
|
||
notice = f"[Komari] 服务器「{info['name']}」已恢复上线。"
|
||
desc = "恢复上线"
|
||
elif not prev["offline"] and info["offline"]:
|
||
notice = f"[Komari] 服务器「{info['name']}」已离线!"
|
||
desc = "离线"
|
||
if notice is None:
|
||
# 本轮无需告警:直接用当前真实状态推进
|
||
_last_state[uuid] = {"name": info["name"], "offline": info["offline"]}
|
||
continue
|
||
if await _notify_admins(
|
||
notice, node_uuid=uuid, server=info["name"],
|
||
status="offline" if info["offline"] else "online"):
|
||
_last_state[uuid] = {"name": info["name"], "offline": info["offline"]}
|
||
print(f"[DEBUG][komari] {info['name']} {desc},已通知管理员")
|
||
else:
|
||
print(f"[DEBUG][komari] {info['name']} {desc},但告警未能投递成功,"
|
||
f"本轮不推进该节点状态,下一轮重发")
|
||
|
||
# 清理已不存在的节点;recent 读取失败的节点仍保留,避免把未知当离线。
|
||
for gone in [u for u in _last_state if u not in current and u not in unreadable]:
|
||
del _last_state[gone]
|
||
|
||
|
||
async def monitor_loop():
|
||
print(f"[DEBUG][komari] 服务器状态监控已启动:{'开' if POLLING_ENABLED else '关'},间隔 {POLLING_MINUTES} 分钟")
|
||
# 启动宽限:等 TG 通道 register 与 NapCat 反向连接完成后再做首次检查,
|
||
# 避免首次离线/恢复告警投递到空通道(_last_state 一旦推进就无法补发)。
|
||
await asyncio.sleep(MONITOR_STARTUP_DELAY)
|
||
while True:
|
||
try:
|
||
await _check_servers_once()
|
||
except Exception as exc:
|
||
print(f"[DEBUG][komari] 轮询异常: {exc}")
|
||
await asyncio.sleep(POLLING_MINUTES * 60)
|
||
|
||
|
||
HELP_TEXT = (
|
||
" Komari 服务器状态\n"
|
||
"━━━━━━━━━━━━\n"
|
||
"• .server status [秒] [序号|all] 查看服务器状态,秒=均值窗口(≤30,默认1)\n"
|
||
"• .server list 查看服务器节点基础信息/到期时间\n"
|
||
"• .server load <节点名> [小时] [指标] 查询节点负载历史\n"
|
||
" 指标: cpu|ram|swap|load|temp|disk|network|process|connections|all\n"
|
||
"• .server polling <on|off> [分钟] 开关服务器状态轮询并设置间隔\n"
|
||
"• .server help 显示本帮助\n"
|
||
"━━━━━━━━━━━━\n"
|
||
"当前轮询: {polling} | 间隔: {minutes} 分钟\n"
|
||
"数据来源: status.rrrosmontis.icu"
|
||
)
|
||
|
||
|
||
async def handle_command(websocket, msg_type, target_id, raw_text):
|
||
global POLLING_ENABLED, POLLING_MINUTES
|
||
parts = (raw_text or "").strip().split()
|
||
sub = parts[1].lower() if len(parts) >= 2 else ""
|
||
|
||
if sub in ("help", "-h", "帮助"):
|
||
await _reply(websocket, msg_type, target_id, HELP_TEXT.format(
|
||
polling="开" if POLLING_ENABLED else "关",
|
||
minutes=POLLING_MINUTES,
|
||
))
|
||
return
|
||
|
||
if sub == "list":
|
||
await _reply(websocket, msg_type, target_id, "正在获取 Komari 节点列表...")
|
||
try:
|
||
token = await _login()
|
||
if not token:
|
||
await _reply(websocket, msg_type, target_id, "Komari 鉴权失败:请检查 komari/setting.json 的 api_key 配置。")
|
||
return
|
||
async with aiohttp.ClientSession() as session:
|
||
nodes_data = await _get_json(session, "/api/nodes", token)
|
||
if nodes_data.get("status") != "success":
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"获取 Komari 节点列表失败: {nodes_data.get('message') or '未知错误'}",
|
||
)
|
||
return
|
||
nodes = nodes_data.get("data") or []
|
||
if not nodes:
|
||
await _reply(websocket, msg_type, target_id, "Komari 中还没有接入任何服务器节点。")
|
||
return
|
||
lines = ["Komari 节点列表", "━━━━━━━━━━━━"]
|
||
for node in nodes:
|
||
lines.append(_fmt_node_list(node))
|
||
msg = "\n".join(lines)
|
||
await _reply(websocket, msg_type, target_id, msg)
|
||
except Exception as exc:
|
||
await _reply(websocket, msg_type, target_id, f"查询 Komari 节点列表失败: {exc}")
|
||
return
|
||
|
||
if sub == "load":
|
||
args = parts[2:]
|
||
if not args:
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
"用法: .server load <节点名> [小时] [指标]\n"
|
||
"示例: .server load Caerula 24 cpu\n"
|
||
"指标: cpu|ram|swap|load|temp|disk|network|process|connections|all",
|
||
)
|
||
return
|
||
name_query, hours, load_type = _parse_load_args(args)
|
||
if not name_query:
|
||
await _reply(websocket, msg_type, target_id, "请指定节点名称,例如 .server load Caerula 24 cpu")
|
||
return
|
||
await _reply(websocket, msg_type, target_id, f"正在查询「{name_query}」最近 {hours} 小时负载 ({load_type})...")
|
||
try:
|
||
token = await _login()
|
||
if not token:
|
||
await _reply(websocket, msg_type, target_id, "Komari 鉴权失败:请检查 komari/setting.json 的 api_key 配置。")
|
||
return
|
||
async with aiohttp.ClientSession() as session:
|
||
nodes_data = await _get_json(session, "/api/nodes", token)
|
||
if nodes_data.get("status") != "success":
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"获取 Komari 节点列表失败: {nodes_data.get('message') or '未知错误'}",
|
||
)
|
||
return
|
||
nodes = nodes_data.get("data") or []
|
||
node, node_name, matches = await _resolve_node(nodes, name_query)
|
||
if node is None:
|
||
if matches:
|
||
names = " / ".join(_node_name(n) for n in matches)
|
||
await _reply(websocket, msg_type, target_id, f"匹配到多个节点: {names},请把名称写完整些")
|
||
else:
|
||
await _reply(websocket, msg_type, target_id, f"未找到名称包含「{name_query}」的节点")
|
||
return
|
||
uuid = node.get("uuid")
|
||
path = f"/api/records/load?uuid={uuid}&hours={hours}&load_type={load_type}"
|
||
records_data = await _get_json(session, path, token)
|
||
if records_data.get("status") != "success":
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"获取负载历史失败: {records_data.get('message') or '未知错误'}",
|
||
)
|
||
return
|
||
records = records_data.get("data", {}).get("records") or []
|
||
lines = [f"【{node_name}】最近 {hours} 小时负载 ({load_type})", "━━━━━━━━━━━━"]
|
||
lines.append(_fmt_record_summary(records, load_type, node))
|
||
msg = "\n".join(lines)
|
||
await _reply(websocket, msg_type, target_id, msg)
|
||
except Exception as exc:
|
||
await _reply(websocket, msg_type, target_id, f"查询负载历史失败: {exc}")
|
||
return
|
||
|
||
if sub == "polling":
|
||
if len(parts) < 3:
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
"用法: .server polling <on|off> [分钟]\n例如: .server polling on 5",
|
||
)
|
||
return
|
||
|
||
onoff = parts[2].strip().lower()
|
||
if onoff not in ("on", "off"):
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
"用法: .server polling <on|off> [分钟]\non=开启, off=关闭",
|
||
)
|
||
return
|
||
|
||
value = None
|
||
if len(parts) >= 4:
|
||
try:
|
||
value = int(parts[3].strip())
|
||
except ValueError:
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
"轮询间隔必须为整数分钟,例如 .server polling on 5",
|
||
)
|
||
return
|
||
if value < 1:
|
||
await _reply(websocket, msg_type, target_id, "轮询间隔至少为 1 分钟")
|
||
return
|
||
|
||
if value is not None:
|
||
POLLING_MINUTES = value
|
||
POLLING_ENABLED = onoff == "on"
|
||
_persist()
|
||
|
||
if POLLING_ENABLED:
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"已开启服务器状态轮询,每 {POLLING_MINUTES} 分钟检查一次",
|
||
)
|
||
else:
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
f"已关闭服务器状态轮询(间隔保持 {POLLING_MINUTES} 分钟)",
|
||
)
|
||
return
|
||
|
||
if sub not in ("status", ""):
|
||
await _reply(
|
||
websocket, msg_type, target_id,
|
||
"用法: .server status [秒] [序号|all] / .server list / .server load <节点名> [小时] [指标] / "
|
||
".server polling <on|off> [分钟] / .server help",
|
||
)
|
||
return
|
||
|
||
duration, index, err = _parse_status_args(parts[2:])
|
||
if err:
|
||
await _reply(websocket, msg_type, target_id, err)
|
||
return
|
||
|
||
# 连续采样耗时较长,放到后台任务执行,避免阻塞消息主循环
|
||
asyncio.create_task(_status_task(websocket, msg_type, target_id, duration, index))
|