255 lines
9.1 KiB
Python
255 lines
9.1 KiB
Python
"""LinuxAwesomeBot 入口:启动 OneBot 反向 WS 服务端 + 命令处理 + 高负载告警。"""
|
||
import asyncio
|
||
import logging
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import monitor
|
||
import templates
|
||
from alerter import Alerter, Reporter
|
||
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()
|
||
reporter = Reporter(self.ob, self.cfg.get("report", {}))
|
||
reporter.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 reporter.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()
|