Files
LinuxAwesomeBot/bot.py
T

252 lines
8.9 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.
"""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()