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:
@@ -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))
|
||||
Reference in New Issue
Block a user