refactor: restructure plugins per MTSS standard, consolidate assets to res/

Per docs/plugin-audit-report.md (plugins normalized to
Trigger(handlers) → Service(services) → Model(repository/models) + utils):

- Split monolithic __init__.py into handlers/services/utils across
  dailywife, deer_pipe, dice, galgame_card, helldivers_tools,
  huoziyinshua, learning_chat, makeaquote, mc_server_status,
  ncm_saying, picfinder_take, picstatus, random_jm_code, regif,
  steam_info, video_analysis, group_tools
- Move static assets under res/: deer_pipe font/img, makeaquote font,
  helldivers img/templates, huoziyinshua HuoZiYinShua
- Add config.py + register_config_items to ncm_saying, random_jm_code,
  group_tools; learning_chat unified config bridge
- Remove deprecated: voice_trans plugin, bf_bot/test.py, dead code in
  dailywife/deer_pipe, empty dirs, debug scripts under helldivers temp
- Disable brash_general_supercredits_tools (stub comment only)
- bot.py: optional stdout/stderr redirect to log file for Web log viewer,
  force ANSI colorize on non-TTY sinks
- Move runtime data (jm_code.json) out of plugin dir into hexi/data
- Docs: plugin-audit-report.md; README reflects removed plugins

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-03 00:44:38 +08:00
co-authored by Claude
parent 25bc30611f
commit 9371a28e35
2389 changed files with 5100 additions and 10127 deletions
@@ -0,0 +1,226 @@
import asyncio
import importlib
import time
from abc import abstractmethod
from collections import deque
from collections.abc import Awaitable, Callable
from contextlib import suppress
from pathlib import Path
from typing import Any, Generic, TypeVar
from typing_extensions import override
from nonebot import logger
from nonebot_plugin_apscheduler import scheduler
from ...config import config
T = TypeVar("T")
TI = TypeVar("TI")
TR = TypeVar("TR")
TC = TypeVar("TC", bound="Collector")
TCF = TypeVar("TCF", bound=Callable[[], Awaitable[Any]])
R = TypeVar("R")
Undefined = type("Undefined", (), {})
class SkipCollectError(Exception):
pass
class Collector(Generic[TI, TR]):
@abstractmethod
async def _get(self) -> TI: ...
@abstractmethod
async def get(self) -> TR: ...
class BaseNormalCollector(Collector[T, T], Generic[T]):
def __init__(self) -> None:
super().__init__()
@override
async def get(self) -> T:
return await self._get()
class BaseFirstTimeCollector(Collector[T, T], Generic[T]):
def __init__(self) -> None:
super().__init__()
self._cached: T | Undefined = Undefined()
@override
async def get(self) -> T:
if not isinstance(self._cached, Undefined):
return self._cached
data = await self._get()
self._cached = data
return data
class BasePeriodicCollector(Collector[T, deque[T]], Generic[T]):
def __init__(self, size: int = config.ps_default_collect_cache_size) -> None:
super().__init__()
self.data = deque(maxlen=size)
@override
async def get(self) -> deque[T]:
return self.data
async def collect(self):
try:
data = await self._get()
except SkipCollectError:
return
except Exception:
logger.exception("Error occurred while collecting data")
else:
self.data.append(data)
registered_collectors: dict[str, type[Collector]] = {}
enabled_collectors: dict[str, Collector] = {}
def collector(name: str):
def deco(cls: type[TC]) -> type[TC]:
if name in registered_collectors:
raise ValueError(f"Collector {name} already exists")
registered_collectors[name] = cls
logger.debug(f"Registered collector {name}")
return cls
return deco
def _enable_collector(name: str):
if name not in registered_collectors:
raise ValueError(f"Collector {name} not found")
cls = registered_collectors[name]
if issubclass(cls, BasePeriodicCollector) and name in config.ps_collect_cache_size:
instance = cls(size=config.ps_collect_cache_size[name])
else:
instance = cls()
enabled_collectors[name] = instance
async def enable_collectors(*names: str):
for name in names:
_enable_collector(name)
await init_first_time_collectors()
await setup_periodic_collectors_update_job()
def functional_collector(cls: type[Collector], name: str | None = None):
def deco(func: TCF) -> TCF:
collector_name = name or func.__name__
if not collector_name:
raise ValueError("name must be provided")
class Collector(cls):
async def _get(self) -> Any:
return await func()
collector(collector_name)(Collector)
return func
return deco
def normal_collector(name: str | None = None):
return functional_collector(BaseNormalCollector, name)
def first_time_collector(name: str | None = None):
return functional_collector(BaseFirstTimeCollector, name)
def periodic_collector(name: str | None = None):
return functional_collector(BasePeriodicCollector, name)
class BaseTimeBasedCounterCollector(Collector[R, Any], Generic[T, R]):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.last_obj: Undefined | T = Undefined()
self.last_time: float = 0
self.normal_delay: float = 1
@abstractmethod
async def _calc(self, past: T, now: T, time_passed: float) -> R: ...
@abstractmethod
async def _get_obj(self) -> T: ...
@override
async def _get(self) -> R:
past = self.last_obj
past_time = self.last_time
time_now = time.time()
time_passed = time_now - past_time
self.last_time = time_now
self.last_obj = await self._get_obj()
if isinstance(past, Undefined):
raise SkipCollectError
return await self._calc(past, self.last_obj, time_passed)
class NormalTimeBasedCounterCollector(
BaseTimeBasedCounterCollector[T, R],
BaseNormalCollector[R],
Generic[T, R],
):
@override
async def get(self) -> R:
with suppress(SkipCollectError):
await self._get()
await asyncio.sleep(self.normal_delay)
return await self._get()
class PeriodicTimeBasedCounterCollector(
BaseTimeBasedCounterCollector[T, R],
BasePeriodicCollector[R],
Generic[T, R],
): ...
async def collect_all() -> dict[str, Any]:
async def get(name: str):
return name, await enabled_collectors[name].get()
res = await asyncio.gather(*(get(name) for name in enabled_collectors))
return dict(res)
def load_builtin_collectors():
for module in Path(__file__).parent.iterdir():
if not module.name.startswith("_"):
importlib.import_module(f".{module.stem}", __package__)
async def init_first_time_collectors():
await asyncio.gather(
*(
x.get()
for x in enabled_collectors.values()
if isinstance(x, BaseFirstTimeCollector)
),
)
async def setup_periodic_collectors_update_job():
collectors = [
x for x in enabled_collectors.values() if isinstance(x, BasePeriodicCollector)
]
if not collectors:
return
logger.debug("Setting up periodic collectors")
@scheduler.scheduled_job("interval", seconds=config.ps_collect_interval)
async def _do():
await asyncio.gather(*(x.collect() for x in collectors))
await _do()
@@ -0,0 +1,93 @@
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from nonebot import get_bots, logger
from nonebot.matcher import current_bot
from ...config import config
from ...misc_statistics import bot_connect_time, bot_info_cache, recv_num, send_num
from ...util import format_timedelta
from . import normal_collector
if TYPE_CHECKING:
from nonebot.adapters import Bot as BaseBot
try:
from nonebot.adapters.onebot.v11 import Bot as OBV11Bot
except ImportError:
OBV11Bot = None
@dataclass
class BotStatus:
self_id: str
adapter: str
nick: str
bot_connected: str
msg_rec: str
msg_sent: str
async def get_ob11_msg_num(bot: "BaseBot") -> tuple[int | None, int | None]:
if not (config.ps_ob_v11_use_get_status and OBV11Bot and isinstance(bot, OBV11Bot)):
return None, None
try:
bot_stat = (await bot.get_status()).get("stat")
except Exception as e:
logger.warning(
f"Error when getting bot status: {e.__class__.__name__}: {e}",
)
return None, None
if not bot_stat:
return None, None
msg_rec = bot_stat.get("message_received") or bot_stat.get(
"MessageReceived",
)
msg_sent = bot_stat.get("message_sent") or bot_stat.get("MessageSent")
return msg_rec, msg_sent
async def get_bot_status(bot: "BaseBot", now_time: datetime) -> BotStatus:
nick = (
((info := bot_info_cache[bot.self_id]).nick or info.name or info.id)
if (not config.ps_use_env_nick) and (bot.self_id in bot_info_cache)
else next(iter(config.nickname), None)
) or "Bot"
bot_connected = (
format_timedelta(now_time - t)
if (t := bot_connect_time.get(bot.self_id))
else "未知"
)
msg_rec, msg_sent = await get_ob11_msg_num(bot)
if msg_rec is None:
msg_rec = recv_num.get(bot.self_id)
if msg_sent is None:
msg_sent = send_num.get(bot.self_id)
msg_rec = "未知" if (msg_rec is None) else str(msg_rec)
msg_sent = "未知" if (msg_sent is None) else str(msg_sent)
return BotStatus(
self_id=bot.self_id,
adapter=bot.adapter.get_name(),
nick=nick,
bot_connected=bot_connected,
msg_rec=msg_rec,
msg_sent=msg_sent,
)
@normal_collector()
async def bots() -> list[BotStatus]:
now_time = datetime.now().astimezone()
return (
[await get_bot_status(current_bot.get(), now_time)]
if config.ps_show_current_bot_only
else await asyncio.gather(
*(get_bot_status(bot, now_time) for bot in get_bots().values()),
)
)
@@ -0,0 +1,70 @@
import asyncio
from dataclasses import dataclass
from typing import cast
import psutil
from cpuinfo import get_cpu_info
from nonebot import logger
from . import first_time_collector, normal_collector, periodic_collector
@dataclass
class CpuFreq:
current: float | None
min: float | None # noqa: A003
max: float | None # noqa: A003
@first_time_collector()
async def cpu_brand() -> str:
try:
brand = (
cast("str", get_cpu_info().get("brand_raw", ""))
.split("@", maxsplit=1)[0]
.strip()
)
if brand.lower().endswith(("cpu", "processor")):
brand = brand.rsplit(maxsplit=1)[0].strip()
except Exception:
logger.exception("Error when getting CPU brand")
return "未知型号"
else:
return brand
@first_time_collector()
async def cpu_count_logical() -> int | None:
return psutil.cpu_count()
@first_time_collector()
async def cpu_count() -> int | None:
return psutil.cpu_count(logical=False)
async def get_cpu_percent() -> float:
"""实时采样 0.5s,避免 psutil 首次调用返回 0.0"""
return await asyncio.to_thread(psutil.cpu_percent, 0.5)
async def get_cpu_percent_periodic() -> float:
"""周期采样:两次调用之间的间隔即为采样区间,直接取瞬时差值即可"""
return psutil.cpu_percent()
normal_collector("cpu_percent")(get_cpu_percent)
periodic_collector("cpu_percent_periodic")(get_cpu_percent_periodic)
async def get_cpu_freq() -> CpuFreq:
cpu_freq = psutil.cpu_freq()
return CpuFreq(
current=getattr(cpu_freq, "current", None),
min=getattr(cpu_freq, "min", None),
max=getattr(cpu_freq, "max", None),
)
normal_collector("cpu_freq")(get_cpu_freq)
periodic_collector("cpu_freq_periodic")(get_cpu_freq)
@@ -0,0 +1,127 @@
from dataclasses import dataclass
from typing import TypeAlias
import psutil
from psutil._common import sdiskpart, sdiskio
from ...config import config
from ...util import match_list_regexp
from . import (
BaseTimeBasedCounterCollector,
NormalTimeBasedCounterCollector,
PeriodicTimeBasedCounterCollector,
collector,
normal_collector,
periodic_collector,
)
@dataclass
class DiskUsageNormal:
name: str
percent: float
used: int
total: int
@dataclass
class DiskUsageWithExc:
name: str
exception: str
DiskUsageType: TypeAlias = DiskUsageNormal | DiskUsageWithExc
@dataclass
class DiskIO:
name: str
read: float
write: float
@periodic_collector()
async def get_disk_usage() -> list[DiskUsageType]:
def get_one(disk: sdiskpart) -> DiskUsageType | None:
mountpoint = disk.mountpoint
if match_list_regexp(config.ps_ignore_parts, mountpoint):
# logger.info(f"空间读取 分区 {mountpoint} 匹配 {regex.re.pattern},忽略")
return None
try:
usage = psutil.disk_usage(mountpoint)
except Exception as e:
# logger.exception(f"读取 {mountpoint} 占用失败")
return (
None
if config.ps_ignore_bad_parts
else DiskUsageWithExc(name=mountpoint, exception=str(e))
)
return DiskUsageNormal(
name=mountpoint,
percent=usage.percent,
used=usage.used,
total=usage.total,
)
usage = [x for x in map(get_one, psutil.disk_partitions()) if x]
if config.ps_sort_parts:
usage.sort(
key=lambda x: x.percent if isinstance(x, DiskUsageNormal) else -1,
reverse=not config.ps_sort_parts_reverse,
)
return usage
normal_collector("disk_usage")(get_disk_usage)
periodic_collector("disk_usage_periodic")(get_disk_usage)
class BaseDiskIOCollector(
BaseTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
):
async def _calc(
self,
past: dict[str, sdiskio],
now: dict[str, sdiskio],
time_passed: float,
) -> list[DiskIO]:
def calc_one(name: str, past_it: sdiskio, now_it: sdiskio) -> DiskIO | None:
if match_list_regexp(config.ps_ignore_disk_ios, name):
# logger.info(f"IO统计 磁盘 {name} 匹配 {regex.re.pattern},忽略")
return None
read = (now_it.read_bytes - past_it.read_bytes) / time_passed
write = (now_it.write_bytes - past_it.write_bytes) / time_passed
if read == 0 and write == 0 and config.ps_ignore_no_io_disk:
# logger.info(f"IO统计 忽略无IO磁盘 {name}")
return None
return DiskIO(name=name, read=read, write=write)
res = [calc_one(name, past[name], now[name]) for name in past if name in now]
res = [x for x in res if x]
if config.ps_sort_disk_ios:
res.sort(key=lambda x: x.read + x.write, reverse=True)
return res
async def _get_obj(self) -> dict[str, sdiskio]:
return psutil.disk_io_counters(perdisk=True)
@collector("disk_io")
class NormalDiskIOCollector(
BaseDiskIOCollector,
NormalTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
): ...
@collector("disk_io_periodic")
class PeriodicDiskIOCollector(
BaseDiskIOCollector,
PeriodicTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
): ...
@@ -0,0 +1,30 @@
from dataclasses import dataclass
import psutil
from . import normal_collector, periodic_collector
@dataclass
class MemoryStat:
percent: float
used: int
total: int
async def get_memory_stat() -> MemoryStat:
mem = psutil.virtual_memory()
return MemoryStat(percent=mem.percent, used=mem.used, total=mem.total)
normal_collector("memory_stat")(get_memory_stat)
periodic_collector("memory_stat_periodic")(get_memory_stat)
async def get_swap_stat() -> MemoryStat:
swap = psutil.swap_memory()
return MemoryStat(percent=swap.percent, used=swap.used, total=swap.total)
normal_collector("swap_stat")(get_swap_stat)
periodic_collector("swap_stat_periodic")(get_swap_stat)
@@ -0,0 +1,130 @@
import os
import platform
import time
from datetime import datetime
from pathlib import Path
import nonebot
import psutil
from ...misc_statistics import nonebot_run_time
from ...util import format_time_delta_ps
from . import first_time_collector, normal_collector, periodic_collector
def parse_env(env: str) -> dict[str, str | None]:
env_lines = env.strip().splitlines()
env_dict: dict[str, str | None] = {}
for line in env_lines:
if "=" not in line:
env_dict[line.upper()] = None
continue
key, value = line.split("=", 1)
env_dict[key.upper()] = value.strip("\"'").strip()
return env_dict
def parse_env_file(env_file: str | Path) -> dict[str, str | None] | None:
if not isinstance(env_file, Path):
env_file = Path(env_file)
if not env_file.exists():
return None
content = env_file.read_text(encoding="u8")
return parse_env(content)
# Thanks to https://github.com/nonedesktop/nonebot-plugin-guestool/blob/main/nonebot_plugin_guestool/info.py
def get_linux_name_version() -> tuple[str, str] | None:
env = parse_env_file("/etc/os-release")
if env and (name := env.get("NAME")) and (version_id := env.get("VERSION_ID")):
return name, version_id
env = parse_env_file("/etc/lsb-release")
if (
env
and (name := env.get("DISTRIB_ID"))
and (version_id := env.get("DISTRIB_RELEASE"))
):
return name, version_id
return None
@normal_collector("nonebot_run_time")
async def nonebot_run_time_str() -> str:
now_time = datetime.now().astimezone()
return (
format_time_delta_ps(now_time - nonebot_run_time)
if nonebot_run_time
else "未知"
)
@normal_collector()
async def system_run_time() -> str:
now_time = datetime.now().astimezone()
return format_time_delta_ps(
now_time - datetime.fromtimestamp(psutil.boot_time()).astimezone(),
)
@first_time_collector()
async def nonebot_version() -> str:
return nonebot.__version__
@first_time_collector()
async def ps_version() -> str:
from ... import __version__
return __version__
async def get_time_str() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
normal_collector("time")(get_time_str)
periodic_collector("time_periodic")(get_time_str)
@first_time_collector()
async def python_version() -> str:
return f"{platform.python_implementation()} {platform.python_version()}"
@first_time_collector()
async def system_name():
system, _, release, version, machine, _ = platform.uname()
system, release, version = platform.system_alias(system, release, version)
if system == "Java":
_, _, _, (system, release, machine) = platform.java_ver()
if system == "Darwin":
return f"MacOS {platform.mac_ver()[0]} {machine}"
if system == "Windows":
return f"Windows {release} {platform.win32_edition()} {machine}"
if system == "Linux":
if (pfx := os.getenv("PREFIX")) and "termux" in pfx:
system = f"Termux (Android) {release}" # a strange platform
elif os.getenv("ANDROID_ROOT") == "/system":
system = f"Linux (Android) {release}"
elif ver := get_linux_name_version():
name, version_id = ver
version = release if version_id.lower() == "rolling" else version_id
system = f"{name} {version}"
else:
system = f"未知 Linux {release}"
return f"{system} {machine}"
return f"{system} {release}"
@@ -0,0 +1,124 @@
import asyncio
import time
from dataclasses import dataclass
from typing import TypeAlias
import psutil
from httpx import AsyncClient, ReadTimeout
from psutil._common import snetio
from ...config import TestSiteCfg, config
from ...util import match_list_regexp
from . import (
BaseTimeBasedCounterCollector,
NormalTimeBasedCounterCollector,
PeriodicTimeBasedCounterCollector,
collector,
normal_collector,
)
@dataclass
class NetworkIO:
name: str
sent: float
recv: float
@dataclass
class NetworkConnectionOK:
name: str
status: int
reason: str
delay: float
@dataclass
class NetworkConnectionError:
name: str
error: str
NetworkConnectionType: TypeAlias = NetworkConnectionOK | NetworkConnectionError
class BaseNetworkIOCollector(
BaseTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
):
async def _calc(
self,
past: dict[str, snetio],
now: dict[str, snetio],
time_passed: float,
) -> list[NetworkIO]:
def calc_one(name: str, past_it: snetio, now_it: snetio) -> NetworkIO | None:
if match_list_regexp(config.ps_ignore_nets, name):
# logger.info(f"网卡IO统计 {name} 匹配 {regex.re.pattern},忽略")
return None
sent = (now_it.bytes_sent - past_it.bytes_sent) / time_passed
recv = (now_it.bytes_recv - past_it.bytes_recv) / time_passed
if sent == 0 and recv == 0 and config.ps_ignore_0b_net:
# logger.info(f"网卡IO统计 忽略无IO网卡 {name}")
return None
return NetworkIO(name=name, sent=sent, recv=recv)
res = [calc_one(name, past[name], now[name]) for name in past if name in now]
res = [x for x in res if x]
if config.ps_sort_nets:
res.sort(key=lambda x: x.sent + x.recv, reverse=True)
return res
async def _get_obj(self) -> dict[str, snetio]:
return psutil.net_io_counters(pernic=True)
@collector("network_io")
class NormalDiskIOCollector(
BaseNetworkIOCollector,
NormalTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
): ...
@collector("network_io_periodic")
class PeriodicDiskIOCollector(
BaseNetworkIOCollector,
PeriodicTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
): ...
@normal_collector()
async def network_connection() -> list[NetworkConnectionType]:
def format_conn_error(error: Exception) -> str:
if isinstance(error, ReadTimeout):
return "超时"
return error.__class__.__name__
async def test_one(site: TestSiteCfg) -> NetworkConnectionType:
try:
async with AsyncClient(
timeout=config.ps_test_timeout,
proxy=config.proxy if site.use_proxy else None,
follow_redirects=True,
) as client:
start = time.time()
resp = await client.get(str(site.url))
delay = (time.time() - start) * 1000
except Exception as e:
return NetworkConnectionError(name=site.name, error=format_conn_error(e))
return NetworkConnectionOK(
name=site.name,
status=resp.status_code,
reason=resp.reason_phrase,
delay=delay,
)
res = await asyncio.gather(*map(test_one, config.ps_test_sites))
if config.ps_sort_sites:
res.sort(key=lambda x: x.delay if isinstance(x, NetworkConnectionOK) else -1)
return res
@@ -0,0 +1,61 @@
import asyncio
from dataclasses import dataclass
from typing import cast
import psutil
from ...config import config
from ...util import match_list_regexp
from . import normal_collector, periodic_collector
@dataclass
class ProcessStatus:
name: str
cpu: float
mem: int
async def get_process_status() -> list[ProcessStatus]:
if not config.ps_proc_len:
return []
async def parse_one(proc: psutil.Process) -> ProcessStatus | None:
name = proc.name()
if match_list_regexp(config.ps_ignore_procs, name):
# logger.info(f"进程 {name} 匹配 {regex.re.pattern},忽略")
return None
# proc.cpu_percent()
# await asyncio.sleep(1)
cpu_count = psutil.cpu_count()
with proc.oneshot():
cpu = proc.cpu_percent()
cpu = (
(cpu / cpu_count) if config.ps_proc_cpu_max_100p and cpu_count else cpu
)
mem: int = proc.memory_info().rss
return ProcessStatus(name=name, cpu=cpu, mem=mem)
def sorter(x: ProcessStatus):
sort_by = config.ps_proc_sort_by
if sort_by == "mem":
return x.mem
# if sort_by == "cpu":
return x.cpu
proc_list = cast(
"list[ProcessStatus | None | Exception]",
await asyncio.gather(
*(parse_one(proc) for proc in psutil.process_iter()),
return_exceptions=True,
),
)
proc_list = [x for x in proc_list if x and (not isinstance(x, Exception))]
proc_list.sort(key=sorter, reverse=True)
return proc_list[: config.ps_proc_len]
normal_collector("process_status")(get_process_status)
periodic_collector("process_status_periodic")(get_process_status)