# 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 [分钟] 开关服务器状态轮询并设置间隔\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 [分钟]\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 [分钟]\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 [分钟] / .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))