Files
shirobana-bot/bot-utility/for_admin_komari_agent.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

912 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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))