Initial import of Shirobana bot backend

Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+911
View File
@@ -0,0 +1,911 @@
# 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))