"""LinuxAwesomeBot 入口:启动 OneBot 反向 WS 服务端 + 命令处理 + 高负载告警。""" import asyncio import logging import sys from pathlib import Path import monitor import templates from alerter import Alerter from onebot import OneBotServer from updater import Updater logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("bot") # ---------- 配置 ---------- def load_config() -> dict: import tomllib import shutil try: with open("config.toml", "rb") as f: return tomllib.load(f) except FileNotFoundError: shutil.copy("config.example.toml", "config.toml") log.warning("未找到 config.toml,已从模板生成,请修改后再运行(先退出)") sys.exit(1) # ---------- 消息格式化(模板见 templates/) ---------- def _fmt_uptime(seconds: float) -> str: d, r = divmod(int(seconds), 86400) h, r = divmod(r, 3600) m = r // 60 return f"{d}天{h}小时{m}分" if d else f"{h}小时{m}分" def _power_str(p: dict) -> str: if p["total_watts"] is not None: src = {"battery": "电池", "rapl": "RAPL"}.get(p["source"], p["source"]) extra = f" (电池 {p['battery']}W)" if p["battery"] is not None and p["source"] == "rapl" else "" return f"{p['total_watts']}W [{src}]{extra}" if p.get("battery_percent") is not None: state = "充电中" if p.get("plugged") else "放电中" return f"电量 {p['battery_percent']}% ({state}),功率不可测" return "不支持(需要电池或Intel RAPL)" def _hottest(temps: dict): flat = [t for entries in temps.values() for t in entries] return max(flat, key=lambda t: t["current"]) if flat else None def fmt_status(s: dict) -> str: c, m = s["cpu"], s["memory"] hot = _hottest(s["temps"]) net = s["net"] d = s["disk"][0] if s["disk"] else None return templates.render( "status", power=_power_str(s["power"]), cpu_total=f"{sum(c['per_core']):.1f}%", cpu_max=f"{c['cores'] * 100}%", cpu_avg=f"{c['percent']}", cores=c["cores"], load=f"{c['load'][0]:.2f}/{c['load'][1]:.2f}/{c['load'][2]:.2f}", memory=f"{m['used_gb']}/{m['total_gb']}GB ({m['percent']}%) Swap {m['swap_percent']}%", temp=f"{hot['label']} {hot['current']}C" if hot else "无数据", net=(f"上行 {net['up_mbps']}Mbps / 下行 {net['down_mbps']}Mbps" if net["up_mbps"] is not None else "无数据"), disk_mount=d["mount"] if d else "-", disk_used=f"{d['used_gb']}/{d['total_gb']}GB" if d else "-", disk_percent=f"{d['percent']}%" if d else "-", uptime=_fmt_uptime(s["uptime_s"]), ) def fmt_power(s: dict) -> str: p = s["power"] if p["total_watts"] is None and p["battery"] is None and p.get("battery_percent") is None: return templates.render( "power", power=("未获取到功耗数据。支持的来源:笔记本电池 /sys/class/power_supply 或系统电池接口," "Intel RAPL /sys/class/powercap (AMD/多数虚拟机/台式机不支持)。"), ) lines = [] if p["battery"] is not None: lines.append(f"电池功率: {p['battery']}W") if p.get("battery_percent") is not None: state = "充电中" if p.get("plugged") else "放电中" lines.append(f"电量: {p['battery_percent']}% ({state})") if p["domains"]: lines.append("RAPL 域:") lines += [f" {name}: {watts}W" for name, watts in p["domains"].items()] return templates.render("power", power="\n".join(lines) or "无数据") def fmt_top(s: dict) -> str: if not s["top"]: return templates.render("top", top_n=10, top_list="无数据") lines = [ f"{i}. {p['name']} (PID {p['pid']}) CPU {p['cpu']}% 内存 {p['mem_mb']}MB" for i, p in enumerate(s["top"], 1) ] return templates.render("top", top_n=len(s["top"]), top_list="\n".join(lines)) def fmt_temps(s: dict) -> str: if not s["temps"]: return templates.render("temps", temp_list="未获取到 (需要 lm-sensors,虚拟机通常不支持)") lines = [ f" {name}/{t['label']}: {t['current']}C" for name, entries in s["temps"].items() for t in entries ] return templates.render("temps", temp_list="\n".join(lines)) def fmt_net(net: dict) -> str: return templates.render( "net", up=f"{net['up_mbps']}Mbps", down=f"{net['down_mbps']}Mbps") # ---------- 命令处理 ---------- class Bot: def __init__(self, cfg: dict): self.cfg = cfg self.ob = OneBotServer( cfg["server"]["host"], cfg["server"]["port"], cfg["server"].get("access_token", ""), ) async def on_event(self, ev: dict): if ev.get("post_type") != "message": return if ev.get("message_type") == "group": text = self._extract_text(ev.get("message")) # 群聊需 @ 或带前缀 if not self._is_command(text) and not self._at_me(ev.get("message")): return elif ev.get("message_type") == "private": text = self._extract_text(ev.get("message")) else: return reply = await self.handle_command(text) if reply is None: return if ev.get("message_type") == "group": await self.ob.send_group_msg(ev["group_id"], reply) else: await self.ob.send_private_msg(ev["user_id"], reply) @staticmethod def _extract_text(message) -> str: if isinstance(message, str): return message.strip() if isinstance(message, list): # CQ 段数组 return "".join( seg.get("data", {}).get("text", "") for seg in message if isinstance(seg, dict) and seg.get("type") == "text" ).strip() return "" @staticmethod def _at_me(message) -> bool: if isinstance(message, list): return any( seg.get("type") == "at" for seg in message if isinstance(seg, dict) ) return "[CQ:at" in (message or "") @staticmethod def _is_command(text: str) -> bool: return text.startswith("/") or text.startswith("!") async def handle_command(self, text: str) -> str | None: cmd = text.lstrip("/!").strip().lower() cmd = cmd.split()[0] if cmd else "" try: if cmd in ("status", "状态", ""): return fmt_status(monitor.get_status()) if cmd in ("power", "功耗"): return fmt_power({"power": monitor.get_power()}) if cmd in ("top", "进程"): return fmt_top({"top": monitor.get_top_processes(10)}) if cmd in ("temp", "温度"): return fmt_temps({"temps": monitor.get_temps()}) if cmd in ("net", "网络"): return fmt_net(monitor.get_net(interval=1.0)) if cmd in ("help", "帮助"): return templates.render("help") return None except Exception: log.exception("处理命令失败: %s", text) return "❌ 命令执行出错,请查看服务端日志。" async def run(self): await self.ob.start(self.on_event) alerter = Alerter(self.ob, self.cfg.get("alert", {})) alerter.start() updater = Updater(self.ob, self.cfg.get("update", {})) if self.cfg.get("update", {}).get("enabled", False): updater.start() watcher = asyncio.get_running_loop().create_task(self._watch_config(alerter)) log.info("机器人就绪,等待 OneBot 实现端连接...") try: await asyncio.Event().wait() except (KeyboardInterrupt, asyncio.CancelledError): pass finally: watcher.cancel() await updater.stop() await alerter.stop() await self.ob.stop() async def _watch_config(self, alerter: Alerter, interval: float = 5.0): """监测 config.toml 修改并热重载(端口/token 等需重启的项不生效)。""" path = Path("config.toml") last = path.stat().st_mtime while True: await asyncio.sleep(interval) try: mtime = path.stat().st_mtime if mtime == last: continue last = mtime cfg = load_config() alerter.apply_config(cfg.get("alert", {})) log.info("配置已热重载 (端口/access_token 变更需重启生效)") except FileNotFoundError: continue except Exception: log.exception("配置热重载失败") def main(): if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) cfg = load_config() asyncio.run(Bot(cfg).run()) if __name__ == "__main__": main()