Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,116 @@
# ruff: noqa: E402
from nonebot import get_driver, require
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
require("nonebot_plugin_apscheduler")
require("nonebot_plugin_alconna")
require("nonebot_plugin_uninfo")
require("nonebot_plugin_localstore")
from . import __main__ as __main__, misc_statistics as misc_statistics
from .bg_provider import bg_preloader
from .collectors import (
enable_collectors,
load_builtin_collectors,
registered_collectors,
)
from .config import ConfigModel, config
from .templates import load_builtin_templates, loaded_templates
# 统一配置标准:把常用、可安全热更的配置暴露给 Web 管理台
# (路径/嵌套模型/复杂 list 字段不改,避免格式互转出错)
from hexi.config_standard import register_model_config # noqa: E402
register_model_config(
__name__,
config,
fields=[
"ps_template",
"ps_command",
"ps_only_su",
"ps_need_at",
"ps_reply_target",
"ps_bg_provider",
"ps_collect_interval",
"ps_proc_len",
"ps_test_timeout",
"ps_show_current_bot_only",
"ps_ob_v11_use_get_status",
],
types={"ps_bg_provider": "enum"},
options={"ps_bg_provider": [
{"value": "loli", "label": "loliapi"},
{"value": "lolicon", "label": "lolicon"},
{"value": "local", "label": "本地背景"},
{"value": "none", "label": "无背景"},
]},
labels={
"ps_template": "状态卡片模板",
"ps_command": "触发指令(每行一个)",
"ps_only_su": "仅超级管理员可用",
"ps_need_at": "需要 @ 机器人",
"ps_reply_target": "回复原消息",
"ps_bg_provider": "背景来源",
"ps_collect_interval": "采集间隔(秒)",
"ps_proc_len": "进程列表长度",
"ps_test_timeout": "测速超时(秒)",
"ps_show_current_bot_only": "只显示当前 bot",
"ps_ob_v11_use_get_status": "OneBot v11 用 get_status",
},
descriptions={
"ps_template": "内置: default",
"ps_command": "每行一个命令,缺省为 运行状态/状态/zt/yxzt/status",
"ps_bg_provider": "loli/lolicon 在线随机图,local 本地, none 关闭",
"ps_collect_interval": "后台采集数据的时间间隔(秒)",
},
)
driver = get_driver()
# lazy load builtin templates and collectors
@driver.on_startup
async def _():
if config.ps_template not in loaded_templates:
load_builtin_templates()
current_template = loaded_templates.get(config.ps_template)
if current_template is None:
raise ValueError(f"Template {config.ps_template} not found")
if (current_template.collectors is None) or any(
(x not in registered_collectors) for x in current_template.collectors
):
load_builtin_collectors()
collectors = (
set(registered_collectors)
if current_template.collectors is None
else current_template.collectors
)
await enable_collectors(*collectors)
bg_preloader.start_preload()
usage = f"指令:{' / '.join(config.ps_command)}"
if config.ps_need_at:
usage += "\n注意:使用指令时需要@机器人"
if config.ps_only_su:
usage += "\n注意:仅SuperUser可以使用此指令"
__version__ = "2.2.2"
__plugin_meta__ = PluginMetadata(
name="PicStatus",
description="以图片形式显示当前设备的运行状态",
usage=usage,
type="application",
homepage="https://github.com/lgc-NB2Dev/nonebot-plugin-picstatus",
config=ConfigModel,
supported_adapters=inherit_supported_adapters(
"nonebot_plugin_alconna",
"nonebot_plugin_uninfo",
),
extra={"License": "MIT", "Author": "LgCookie"},
)
@@ -0,0 +1,81 @@
import asyncio
from cookit.loguru import warning_suppress
from cookit.nonebot.alconna import extract_reply_msg
from nonebot import logger, on_command
from nonebot.adapters import Bot as BaseBot, Event as BaseEvent, Message as BaseMessage
from nonebot.matcher import current_bot, current_event, current_matcher
from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER
from nonebot.rule import Rule, to_me
from nonebot.typing import T_State
from nonebot_plugin_alconna.uniseg import Image, OriginalUniMsg, UniMessage, image_fetch
from .bg_provider import BgBytesData, bg_preloader
from .collectors import collect_all
from .config import config
from .misc_statistics import bot_avatar_cache, bot_info_cache, cache_bot_avatar
from .templates import render_current_template
def check_empty_arg_rule(arg: BaseMessage = CommandArg()):
return not arg.extract_plain_text()
def trigger_rule():
rule = Rule(check_empty_arg_rule)
if config.ps_need_at:
rule &= to_me()
return rule
_cmd, *_alias = config.ps_command
stat_matcher = on_command(
_cmd,
aliases=set(_alias),
rule=trigger_rule(),
permission=SUPERUSER if config.ps_only_su else None,
)
async def get_pic_from_msg(msg: UniMessage) -> BgBytesData | None:
msg = r if ((r := extract_reply_msg(msg)) and Image in r) else msg
if Image not in msg:
return None
img = msg[Image, 0]
data = await image_fetch(
current_event.get(),
current_bot.get(),
current_matcher.get().state,
img,
)
if not data:
return None
return BgBytesData(data=data, mime=img.mimetype or "image")
@stat_matcher.handle()
async def _(bot: BaseBot, event: BaseEvent, state: T_State, msg: OriginalUniMsg):
if (
(bot.self_id not in bot_avatar_cache)
and (info := bot_info_cache.get(bot.self_id))
and info.avatar
):
await cache_bot_avatar(info.avatar, bot, event, state)
async def get_bg():
with warning_suppress("Failed to fetch image from user message"):
if bg := await get_pic_from_msg(msg):
return bg
return await bg_preloader.get()
try:
bg, collected = await asyncio.gather(get_bg(), collect_all())
ret = await render_current_template(collected=collected, bg=bg)
except Exception:
logger.exception("获取运行状态图失败")
await UniMessage("获取运行状态图片失败,请检查后台输出").send(
reply_to=config.ps_reply_target,
)
else:
await UniMessage.image(raw=ret).send(reply_to=config.ps_reply_target)
@@ -0,0 +1,411 @@
import asyncio as aio
import mimetypes
import random
import sys
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Callable
from math import floor
from pathlib import Path
from typing import Generic, NamedTuple, ParamSpec, TypeAlias, TypedDict, TypeVar
from typing_extensions import override
from cookit.common import race
from cookit.loguru import warning_suppress
from httpx import AsyncClient, Response
from nonebot import get_driver, logger
from .config import BG_PRELOAD_CACHE_DIR, DEFAULT_BG_PATH, config
if sys.version_info >= (3, 11):
from asyncio.taskgroups import TaskGroup
else:
from taskgroup import TaskGroup
class BgBytesData(NamedTuple):
data: bytes | None
mime: str
class BgFileData(NamedTuple):
path: Path | None
mime: str
BgData: TypeAlias = BgBytesData | BgFileData
BGProviderType = Callable[[int], AsyncIterable[BgData]]
T = TypeVar("T")
TBP = TypeVar("TBP", bound=BGProviderType)
P = ParamSpec("P")
DEFAULT_MIME = "application/octet-stream"
registered_bg_providers: dict[str, BGProviderType] = {}
def get_bg_files() -> list["Path"]:
if not config.ps_bg_local_path.exists():
logger.warning("Custom background path does not exist, fallback to default")
return [DEFAULT_BG_PATH]
if config.ps_bg_local_path.is_file():
return [config.ps_bg_local_path]
files = [x for x in config.ps_bg_local_path.glob("*") if x.is_file()]
if not files:
logger.warning("Custom background dir has no file in it, fallback to default")
return [DEFAULT_BG_PATH]
return files
BG_FILES = get_bg_files()
def refresh_bg_files():
global BG_FILES
BG_FILES = get_bg_files()
def bg_provider(name: str | None = None):
def deco(func: TBP) -> TBP:
provider_name = name or func.__name__
if provider_name in registered_bg_providers:
raise ValueError(f"Duplicate bg provider name `{provider_name}`")
registered_bg_providers[provider_name] = func
return func
return deco
def iter_batch_sizes(size: int, max_size: int):
if size <= max_size:
yield size
else:
full_sizes = floor(max_size / size)
for _ in range(full_sizes):
yield max_size
if rest_count := full_sizes * max_size:
yield rest_count
def resp_to_bg_data(resp: Response):
return BgBytesData(
resp.content,
(resp.headers.get("Content-Type") or DEFAULT_MIME),
)
class CoIterator(ABC, Generic[T]):
def __init__(self):
self.queue = aio.Queue[T | None]()
@abstractmethod
async def run_tasks(self): ...
async def run(self):
await self.run_tasks()
await self.queue.put(None)
async def __aiter__(self):
async with TaskGroup() as t:
t.create_task(self.run())
while (x := await self.queue.get()) is not None:
yield x
@bg_provider("loli")
class LoliBGProvider(CoIterator[BgData]):
def __init__(self, num: int):
super().__init__()
self.num = num
self.sem = aio.Semaphore(4)
async def task_piece(self, cli: AsyncClient):
async with self.sem:
with warning_suppress("Failed to fetch image"):
x = resp_to_bg_data(
(
await cli.get("https://www.loliapi.com/acg/pe/")
).raise_for_status(),
)
await self.queue.put(x)
@override
async def run_tasks(self):
async with AsyncClient(
follow_redirects=True,
proxy=config.proxy,
timeout=config.ps_req_timeout,
) as cli:
await aio.gather(*(self.task_piece(cli) for _ in range(self.num)))
class LoliconRespDataUrls(TypedDict):
original: str
class LoliconRespData(TypedDict):
urls: LoliconRespDataUrls
class LoliconResp(TypedDict):
data: list[LoliconRespData]
@bg_provider("lolicon")
class LoliconBGProvider(CoIterator[BgData]):
def __init__(self, num: int):
super().__init__()
self.num = num
self.sem = aio.Semaphore(4)
self.url_queue = aio.Queue[str | None]()
async def do_fetch_urls_piece(self, num: int, cli: AsyncClient):
with warning_suppress("Failed to fetch urls"):
resp = await cli.get(
"https://api.lolicon.app/setu/v2",
params={
"num": num,
"r18": config.ps_bg_lolicon_r18_type,
"proxy": "false",
"excludeAI": "true",
},
)
data: LoliconResp = resp.raise_for_status().json()
for x in data["data"]:
await self.url_queue.put(x["urls"]["original"])
async def fetch_urls_task_f(self):
async with AsyncClient(
follow_redirects=True,
proxy=config.proxy,
timeout=config.ps_req_timeout,
) as cli:
for x in iter_batch_sizes(self.num, 20):
await self.do_fetch_urls_piece(x, cli)
await self.url_queue.put(None)
async def fetch_image(self, url: str, cli: AsyncClient):
async with self.sem:
with warning_suppress("Failed to fetch image"):
bg = resp_to_bg_data((await cli.get(url)).raise_for_status())
await self.queue.put(bg)
@override
async def run_tasks(self):
pixiv_client = AsyncClient(
follow_redirects=True,
proxy=config.proxy,
timeout=config.ps_req_timeout,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/119.0.0.0 "
"Safari/537.36"
),
"Referer": "https://www.pixiv.net/",
},
)
async with TaskGroup() as t, pixiv_client:
t.create_task(self.fetch_urls_task_f())
while (x := await self.url_queue.get()) is not None:
t.create_task(self.fetch_image(x, pixiv_client))
@bg_provider()
async def local(num: int):
files = random.sample(BG_FILES, num)
# logger.debug(f"Chosen background `{files}`")
for x in files:
yield BgFileData(
x,
mimetypes.guess_type(x)[0] or DEFAULT_MIME,
)
def create_none_bg():
return BgBytesData(None, DEFAULT_MIME)
@bg_provider()
async def none(num: int):
for _ in range(num):
yield create_none_bg()
async def fetch_bg(num: int) -> AsyncIterable[BgData]:
if config.ps_bg_provider not in registered_bg_providers:
logger.warning(
f"Unknown background provider `{config.ps_bg_provider}`, fallback to local",
)
async for x in local(num):
yield x
return
# at least we should return one image (x)
# has_img = False
try:
provider = registered_bg_providers[config.ps_bg_provider]
async for x in provider(num):
# has_img = True
yield x
except Exception:
logger.exception(
"Error when getting background, fallback to get one local bg",
)
async for x in local(1):
yield x
# else:
# if has_img:
# return
# logger.warning(
# "Background provider returned empty iterator, fallback to get one local bg",
# )
# async for x in local(1):
# yield x
def cache_bg(bg: BgBytesData):
if not bg.data:
return BgFileData(None, bg.mime)
BG_PRELOAD_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = BG_PRELOAD_CACHE_DIR / f"{time.time_ns()}.{bg.mime.split('/')[-1]}"
path.write_bytes(bg.data)
return BgFileData(path, bg.mime)
def read_cached_bg_file(bg: BgFileData) -> BgBytesData | None:
if not bg.path:
return BgBytesData(None, bg.mime)
with warning_suppress("Failed to read cached file"):
data = bg.path.read_bytes()
if bg.path.is_relative_to(BG_PRELOAD_CACHE_DIR):
with warning_suppress("Failed to unlink cached file"):
bg.path.unlink()
return BgBytesData(data, bg.mime)
return None
async def get_one_fallback() -> BgBytesData:
with warning_suppress("Failed to get local bg file, fallback to none"):
async for x in local(1):
if bg := read_cached_bg_file(x):
return bg
logger.warning("Failed to read local bg file, fallback to none")
return create_none_bg()
class BgPreloader:
def __init__(self, preload_count: int):
# if preload_count < 1:
# raise ValueError("preload_count must be greater than or equals 1")
self.preload_count = preload_count
self.background_queue = aio.Queue[BgData]()
self.current_load_task_main: aio.Task | None = None
self.consumed_in_loading: bool = False
self.image_got_signal = aio.Event()
self.fire_tasks: set[aio.Task] = set()
# we allow fetch_bg return less image than we require
async def preload_task(
self,
count: int,
fire: bool = False,
fire_done_signal: aio.Event | None = None,
):
logger.debug(f"Preload task started, will preload {count} images, {fire=}")
try:
async for x in fetch_bg(count):
logger.debug("Got one image")
if self.preload_count > 0 or (
fire_done_signal and fire_done_signal.is_set()
):
x = cache_bg(x) if isinstance(x, BgBytesData) else x
await self.background_queue.put(x)
self.image_got_signal.set()
self.image_got_signal.clear()
except Exception:
logger.exception("Unexpected error occurred in preload task")
else:
logger.debug("Preload task finished")
if fire:
return
if (
self.consumed_in_loading
or self.background_queue.qsize() < self.preload_count
):
self.consumed_in_loading = False
self.start_preload()
else:
self.current_load_task_main = None
def start_preload(self, force: bool = False):
count = self.preload_count - self.background_queue.qsize()
if count <= 0 and not force:
logger.debug(
"Current background queue size meets preload count, skip preload",
)
return
task = aio.create_task(self.preload_task(count))
self.current_load_task_main = task
def set_defer_preload(self):
if self.current_load_task_main:
logger.debug("Main preload task already running, set flag")
self.consumed_in_loading = True
else:
self.start_preload()
async def _get_on_fire(self) -> BgBytesData:
task_done_signal = aio.Event()
fire_task = aio.create_task(
self.preload_task(1, fire=True, fire_done_signal=task_done_signal),
)
fire_task.add_done_callback(lambda _: task_done_signal.set())
fire_task.add_done_callback(lambda _: self.fire_tasks.discard(fire_task))
self.fire_tasks.add(fire_task)
try:
await race(
# self.image_got_signal.wait(), # lazy to handle this racing condition now
task_done_signal.wait(),
aio.sleep(15),
)
finally:
task_done_signal.set()
# fire_task.cancel() # should we cancel here? i'm letting it cache to queue
if not self.background_queue.empty():
bg = await self.background_queue.get()
self.set_defer_preload()
if (not isinstance(bg, BgFileData)) or (bg := read_cached_bg_file(bg)):
return bg
logger.error("Unable to get an background image, falling back to local")
return await get_one_fallback()
async def get(self) -> BgBytesData:
self.set_defer_preload()
while not self.background_queue.empty():
bg = await self.background_queue.get()
self.set_defer_preload()
if (not isinstance(bg, BgFileData)) or (bg := read_cached_bg_file(bg)):
return bg
# normally all items in queue should be valid
# if they not, we should fetch
return await self._get_on_fire()
bg_preloader = BgPreloader(config.ps_bg_preload_count)
driver = get_driver()
@driver.on_shutdown
async def _():
for t in bg_preloader.fire_tasks:
t.cancel()
@@ -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)
@@ -0,0 +1,118 @@
import shutil
from pathlib import Path
from typing import Literal
from cookit.nonebot.localstore import ensure_localstore_path_config
from nonebot import get_plugin_config
from nonebot.compat import type_validate_python
from nonebot_plugin_localstore import get_plugin_cache_dir
from pydantic import AnyHttpUrl, BaseModel, Field
ensure_localstore_path_config()
CACHE_DIR = get_plugin_cache_dir()
BG_PRELOAD_CACHE_DIR = CACHE_DIR / "bg_preload"
if BG_PRELOAD_CACHE_DIR.exists():
shutil.rmtree(BG_PRELOAD_CACHE_DIR)
BG_PRELOAD_CACHE_DIR.mkdir(parents=True, exist_ok=True)
RES_PATH = Path(__file__).parent / "res"
ASSETS_PATH = RES_PATH / "assets"
TEMPLATE_PATH = RES_PATH / "templates"
DEFAULT_BG_PATH = ASSETS_PATH / "default_bg.webp"
DEFAULT_AVATAR_PATH = ASSETS_PATH / "default_avatar.webp"
ProcSortByType = Literal["cpu", "mem"]
class TestSiteCfg(BaseModel):
name: str
url: AnyHttpUrl
use_proxy: bool = False
class ConfigModel(BaseModel):
# region builtin
superusers: set[str]
nickname: set[str]
# endregion
# region global
proxy: str | None = None
# endregion
# region behavior
ps_template: str = "default"
ps_command: list[str] = ["运行状态", "状态", "zt", "yxzt", "status"]
ps_only_su: bool = False
ps_need_at: bool = False
ps_reply_target: bool = True
ps_req_timeout: int | None = 10
# endregion
# region style
ps_bg_provider: str = "loli"
ps_bg_preload_count: int = 2
ps_bg_lolicon_r18_type: Literal[0, 1, 2] = 0
ps_bg_local_path: Path = DEFAULT_BG_PATH
ps_default_avatar: Path = DEFAULT_AVATAR_PATH
# endregion
# region collectors
# region base
ps_collect_interval: int = 5
ps_default_collect_cache_size: int = 1
ps_collect_cache_size: dict[str, int] = Field(default_factory=dict)
# endregion
# region header
ps_use_env_nick: bool = False
ps_show_current_bot_only: bool = False
ps_ob_v11_use_get_status: bool = True
ps_count_message_sent_event: bool | set[str] = False
ps_disconnect_reset_counter: bool = True
# endregion
# region disk
# usage
ps_ignore_parts: list[str] = []
ps_ignore_bad_parts: bool = False
ps_sort_parts: bool = True
ps_sort_parts_reverse: bool = False
# io
ps_ignore_disk_ios: list[str] = [r"^(loop|zram)\d*$"]
ps_ignore_no_io_disk: bool = False
ps_sort_disk_ios: bool = True
# endregion
# region network
# io
ps_ignore_nets: list[str] = [r"^lo(op)?\d*$|^(Loopback|本地连接)"]
ps_ignore_0b_net: bool = False
ps_sort_nets: bool = True
# connection_test
ps_test_sites: list[TestSiteCfg] = [ # v1 compat #59
type_validate_python(
TestSiteCfg,
{"name": "百度", "url": "https://www.baidu.com/"},
),
type_validate_python(
TestSiteCfg,
{"name": "Google", "url": "https://www.google.com/", "use_proxy": True},
),
]
ps_sort_sites: bool = True
ps_test_timeout: int = 5
# endregion
# region process
ps_proc_len: int = 5
ps_ignore_procs: list[str] = ["^System Idle Process$"]
ps_proc_sort_by: ProcSortByType = "cpu"
ps_proc_cpu_max_100p: bool = False
# endregion
# endregion components
config: ConfigModel = get_plugin_config(ConfigModel)
@@ -0,0 +1,153 @@
from collections.abc import Callable
from datetime import datetime
from typing import Any
from cookit.loguru import log_exception_warning
from nonebot import get_driver, logger
from nonebot.adapters import Bot as BaseBot, Event as BaseEvent
from nonebot.message import event_preprocessor
from nonebot.typing import T_State
from nonebot_plugin_alconna import image_fetch
from nonebot_plugin_alconna.uniseg import Image
from nonebot_plugin_uninfo import User, get_interface
from .config import config
nonebot_run_time: datetime = datetime.now().astimezone()
bot_connect_time: dict[str, datetime] = {}
recv_num: dict[str, int] = {}
send_num: dict[str, int] = {}
bot_info_cache: dict[str, User] = {}
bot_avatar_cache: dict[str, bytes | None] = {}
driver = get_driver()
SEND_APIS: dict[str, list[str] | Callable[[str], bool]] = {
# "BilibiliLive": [], # 狗东西发消息不走 call_api
"Console": ["send_msg"],
"Ding": ["send"],
"Discord": ["create_message"],
"Feishu": ["im/v1/messages"],
"Kaiheila": ["message_create", "directMessage_create"],
"Minecraft": ["send_msg"],
"mirai2": ["send_friend_message", "send_group_message", "send_temp_message"],
"ntchat": lambda x: x.startswith("send_"),
"OneBot V11": ["send_private_msg", "send_group_msg", "send_msg"],
"OneBot V12": ["send_message"],
"QQ": [
"post_dms_messages",
"post_messages",
"post_c2c_messages",
"post_c2c_files",
"post_group_messages",
"post_group_files",
],
"RedProtocol": ["send_message", "send_fake_forward"],
"Satori": ["message_create"],
"Telegram": lambda x: x.startswith("send_"),
"大别野": ["send_message"],
}
def method_is_send_msg(platform: str, name: str) -> bool:
return (platform in SEND_APIS) and (
(name in it) if isinstance((it := SEND_APIS[platform]), list) else it(name)
)
if config.ps_count_message_sent_event:
@event_preprocessor
async def _(bot: BaseBot, event: BaseEvent):
if (
config.ps_count_message_sent_event
and (
(config.ps_count_message_sent_event is True)
or bot.adapter.get_name() in config.ps_count_message_sent_event
)
and (
(event.get_type() == "message_sent")
or (
event.get_type() == "message" and event.get_user_id() == bot.self_id
)
)
):
# logger.debug(f"Bot {bot.self_id} sent counter +1")
send_num[bot.self_id] += 1
if config.ps_count_message_sent_event is not True:
@BaseBot.on_called_api
async def called_api(
bot: BaseBot,
exc: Exception | None,
api: str,
_: dict[str, Any],
__: Any,
):
if (
(not exc)
and (config.ps_count_message_sent_event is not True)
and (
(config.ps_count_message_sent_event is False)
or (bot.adapter.get_name() not in config.ps_count_message_sent_event)
)
and method_is_send_msg(bot.adapter.get_name(), api)
):
# logger.debug(f"Bot {bot.self_id} sent counter +1")
send_num[bot.self_id] += 1
async def cache_bot_avatar(avatar: str, bot: BaseBot, event: BaseEvent, state: T_State):
try:
img = await image_fetch(event, bot, state, Image(url=avatar))
except Exception as e:
log_exception_warning(e, f"Failed to get avatar of bot {bot.self_id}")
return None
else:
if not img:
logger.warning(
f"Cannot get avatar of bot {bot.self_id}"
f" because image_fetch returned None",
)
bot_avatar_cache[bot.self_id] = img
return img
async def cache_bot_info(bot: BaseBot):
try:
it = get_interface(bot)
info = (await it.get_user(bot.self_id)) if it else None
except Exception as e:
logger.warning(f"Error when getting bot info: {e.__class__.__name__}: {e}")
else:
if info:
bot_info_cache[bot.self_id] = info
return info
return None
@driver.on_bot_connect
async def _(bot: BaseBot):
bot_connect_time[bot.self_id] = datetime.now().astimezone()
if bot.self_id not in recv_num:
recv_num[bot.self_id] = 0
if (bot.self_id not in send_num) and (bot.adapter.get_name() in SEND_APIS):
send_num[bot.self_id] = 0
await cache_bot_info(bot)
@driver.on_bot_disconnect
async def _(bot: BaseBot):
bot_connect_time.pop(bot.self_id, None)
if config.ps_disconnect_reset_counter:
recv_num.pop(bot.self_id, None)
send_num.pop(bot.self_id, None)
@event_preprocessor
async def _(bot: BaseBot, event: BaseEvent):
if event.get_type() == "message":
recv_num[bot.self_id] += 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

@@ -0,0 +1,3 @@
declare module globalThis {
var plugins: (() => Promise<any>)[];
}
@@ -0,0 +1 @@
globalThis.plugins = [];
@@ -0,0 +1,77 @@
/// <reference path="./globalThis.d.ts" />
(() => {
/**
* @template {any[]} A
* @template R
* @param {(...args: A) => Promise<R>} func
* @returns {(...args: A) => Promise<R | undefined>}
*/
function wrapErr(func) {
return async (...args) => {
try {
return await func(...args);
} catch (e) {
console.error(e);
}
};
}
/**
* @param {string} url
*/
async function makeObjUrlFromUrl(url) {
const res = await fetch(url);
const blob = await res.blob();
return URL.createObjectURL(blob);
}
/** @typedef {(elem: HTMLElement, objUrl: string) => Promise<any>} PropSetterType */
/** @type {Record<string, PropSetterType>} */
const propSetterMap = {
'data-background-image': async (elem, objUrl) => {
elem.style.backgroundImage = `url(${objUrl})`;
},
'data-src': async (elem, objUrl) => {
if (elem instanceof HTMLImageElement) elem.src = objUrl;
},
};
/**
* @param {HTMLElement} elem
* @param {string} attr
* @param {PropSetterType} setter
*/
async function lazyLoadOne(elem, attr, setter) {
const url = elem.getAttribute(attr);
if (!url) return;
const objUrl = await makeObjUrlFromUrl(url);
await setter(elem, objUrl);
elem.removeAttribute(attr);
}
/**
* @param {string} attr
*/
async function lazyLoad(attr) {
/** @type {HTMLElement[]} */
// @ts-ignore
const elements = [...document.body.querySelectorAll(`[${attr}]`)].filter(
(v) => v instanceof HTMLElement
);
const tasks = elements.map((v) =>
wrapErr(lazyLoadOne)(v, attr, propSetterMap[attr])
);
await Promise.all(tasks);
}
async function lazyLoadAll() {
await Promise.all(Object.keys(propSetterMap).map(lazyLoad));
}
// 使用 globalThis.plugins.push 注册插件
// 只有这样才能保证你的插件运行完成之后才会网页截图
globalThis.plugins.push(async () => {
await lazyLoadAll();
});
})();
@@ -0,0 +1,10 @@
(async () => {
for (const plugin of globalThis.plugins) {
try {
await plugin();
} catch (e) {
console.error(e);
}
}
document.body.classList.add('done');
})();
@@ -0,0 +1,65 @@
import importlib
from collections.abc import Awaitable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict
from typing_extensions import Protocol, Unpack
from nonebot import logger
from ..config import config
if TYPE_CHECKING:
from ..bg_provider import BgBytesData
class TemplateRendererKwargs(TypedDict):
collected: dict[str, Any]
bg: "BgBytesData"
class TemplateRenderer(Protocol):
__name__: str
def __call__(
self,
**kwargs: Unpack[TemplateRendererKwargs],
) -> Awaitable[bytes]: ...
@dataclass()
class TemplateInfo:
renderer: TemplateRenderer
collectors: set[str] | None = None
loaded_templates: dict[str, TemplateInfo] = {}
def pic_template(
name: str | None = None,
collecting: set[str] | None = None,
):
def deco(func: TemplateRenderer):
template_name = name or func.__name__
if template_name in loaded_templates:
raise ValueError(f"Template {template_name} already exists")
loaded_templates[template_name] = TemplateInfo(
renderer=func,
collectors=collecting,
)
logger.debug(f"Registered template {template_name}")
return deco
def load_builtin_templates():
for module in Path(__file__).parent.iterdir():
name = module.name
if (not module.is_dir()) or name.startswith("_"):
continue
assert importlib.import_module(f".{name}", __package__)
async def render_current_template(**kwargs: Unpack[TemplateRendererKwargs]):
return await loaded_templates[config.ps_template].renderer(**kwargs)
@@ -0,0 +1,140 @@
from collections import deque
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
import jinja2
from cookit import flatten
from cookit.pyd import field_validator
from nonebot import get_plugin_config, require
from pydantic import BaseModel
from ...util import debug
from .. import pic_template
from ..pw_render import (
ROUTE_URL,
add_background_router,
add_root_router,
base_router_group,
make_file_router,
register_global_filter_to,
resolve_file_url,
)
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import get_new_page # noqa: E402
if TYPE_CHECKING:
from ...bg_provider import BgBytesData
RES_PATH = Path(__file__).parent / "res"
TEMPLATE_PATH = RES_PATH / "templates"
CSS_PATH = RES_PATH / "css"
ENVIRONMENT = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_PATH)),
autoescape=jinja2.select_autoescape(["html", "xml"]),
enable_async=True,
)
register_global_filter_to(ENVIRONMENT)
template_router_group = base_router_group.copy()
template_router_group.router(f"{ROUTE_URL}/default/res/**/*", priority=99)(
make_file_router(query_name=None, base_path=RES_PATH, prefix_omit="default/res/"),
)
COMPONENT_COLLECTORS = {
"header": {"bots", "nonebot_run_time", "system_run_time"},
"cpu_mem": {
"cpu_percent",
"cpu_count",
"cpu_count_logical",
"cpu_freq",
"cpu_brand",
"memory_stat",
"swap_stat",
},
"disk": {"disk_usage", "disk_io"},
"network": {"network_io", "network_connection"},
"process": {"process_status"},
"footer": {
"nonebot_version",
"ps_version",
"time",
"python_version",
"system_name",
},
}
PERIODIC_COLLECTORS_MAP = {
"cpu_percent": "cpu_percent_periodic",
"cpu_freq": "cpu_freq_periodic",
"disk_usage": "disk_usage_periodic",
"disk_io": "disk_io_periodic",
"memory_stat": "memory_stat_periodic",
"swap_stat": "swap_stat_periodic",
"time": "time_periodic",
"network_io": "network_io_periodic",
"process_status": "process_status_periodic",
}
PERIODIC_COLLECTORS_MAP_REVERSE = {v: k for k, v in PERIODIC_COLLECTORS_MAP.items()}
class TemplateConfig(BaseModel):
ps_default_components: list[str] = [
"header",
"cpu_mem",
"disk",
"network",
"process",
"footer",
]
ps_default_additional_css: list[str] = []
ps_default_additional_script: list[str] = []
ps_default_pic_format: Literal["jpeg", "png"] = "jpeg"
ps_default_use_periodic: bool = True
@field_validator("ps_default_additional_css")
def resolve_css_url(cls, v: list[str]): # noqa: N805
return [resolve_file_url(x, {"default/res/css": CSS_PATH}) for x in v]
@field_validator("ps_default_additional_script")
def resolve_script_url(cls, v: list[str]): # noqa: N805
return [resolve_file_url(x) for x in v]
template_config = get_plugin_config(TemplateConfig)
collecting = set(
flatten(COMPONENT_COLLECTORS[k] for k in template_config.ps_default_components),
)
if template_config.ps_default_use_periodic:
collecting = {(PERIODIC_COLLECTORS_MAP.get(x) or x) for x in collecting}
@pic_template(collecting=collecting)
async def default(collected: dict[str, Any], bg: "BgBytesData", **_) -> bytes:
for k, v in collected.copy().items():
if (
template_config.ps_default_use_periodic
and k in PERIODIC_COLLECTORS_MAP_REVERSE
):
del collected[k]
k = PERIODIC_COLLECTORS_MAP_REVERSE[k]
if isinstance(v, deque):
collected[k] = v[-1] if v else None
template = ENVIRONMENT.get_template("index.html.jinja")
html = await template.render_async(d=collected, config=template_config)
if debug.enabled:
debug.write(html, "default_{time}.html")
router_group = template_router_group.copy()
add_root_router(router_group, html)
add_background_router(router_group, bg)
async with get_new_page() as page:
await router_group.apply(page)
await page.goto(f"{ROUTE_URL}/")
await page.wait_for_selector("body.done")
elem = await page.query_selector(".main-background")
assert elem
return await elem.screenshot(type="jpeg")
@@ -0,0 +1,369 @@
/* Base */
* {
--font-family: 'HarmonyOS Sans SC', 'Source Han Sans SC', 'Source Han Sans',
sans-serif;
--monospace-font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
monospace;
--primary-text-color: #3a3a3a;
--secondary-text-color: #6a6a6a;
--default-background: #3a3a3aaa;
--background-mask-color: #fafafa66;
--card-background-color: #fafafaaa;
--default-box-shadow: 2px 2px 6px #6a6a6a66;
--label-red-text-color: var(--secondary-text-color);
--label-orange-text-color: var(--secondary-text-color);
--label-yellow-text-color: var(--secondary-text-color);
--label-green-text-color: var(--secondary-text-color);
--label-cyan-text-color: var(--secondary-text-color);
--label-blue-text-color: var(--secondary-text-color);
--label-purple-text-color: var(--secondary-text-color);
--label-gray-text-color: var(--secondary-text-color);
--label-black-text-color: #fafafa;
--background-color: var(--card-background-color);
--label-red-bg-color: #e05661aa;
--label-orange-bg-color: #ee9025aa;
--label-yellow-bg-color: #eea825aa;
--label-green-bg-color: #1da912aa;
--label-cyan-bg-color: #56b6c2aa;
--label-blue-bg-color: #118dc3aa;
--label-purple-bg-color: #9a77cfaa;
--label-gray-bg-color: #bebebeaa;
--label-black-bg-color: #3a3a3aaa;
--prog-low-text-color: var(--secondary-text-color);
--prog-medium-text-color: var(--secondary-text-color);
--prog-high-text-color: var(--secondary-text-color);
--prog-low-bg-color: var(--label-green-bg-color);
--prog-medium-bg-color: var(--label-orange-bg-color);
--prog-high-bg-color: var(--label-red-bg-color);
--segment-color: #bebebe;
}
.red {
--background-color: var(--label-red-bg-color);
--secondary-text-color: var(--label-red-text-color);
}
.orange {
--background-color: var(--label-orange-bg-color);
--secondary-text-color: var(--label-orange-text-color);
}
.yellow {
--background-color: var(--label-yellow-bg-color);
--secondary-text-color: var(--label-yellow-text-color);
}
.green {
--background-color: var(--label-green-bg-color);
--secondary-text-color: var(--label-green-text-color);
}
.cyan {
--background-color: var(--label-cyan-bg-color);
--secondary-text-color: var(--label-cyan-text-color);
}
.blue {
--background-color: var(--label-blue-bg-color);
--secondary-text-color: var(--label-blue-text-color);
}
.purple {
--background-color: var(--label-purple-bg-color);
--secondary-text-color: var(--label-purple-text-color);
}
.gray {
--background-color: var(--label-gray-bg-color);
--secondary-text-color: var(--label-gray-text-color);
}
.black {
--background-color: var(--label-black-bg-color);
--secondary-text-color: var(--label-black-text-color);
}
.prog-low {
--background-color: var(--prog-low-bg-color);
--secondary-text-color: var(--prog-low-text-color);
}
.prog-medium {
--background-color: var(--prog-medium-bg-color);
--secondary-text-color: var(--prog-medium-text-color);
}
.prog-high {
--background-color: var(--prog-high-bg-color);
--secondary-text-color: var(--prog-high-text-color);
}
.monospace {
font-family: var(--monospace-font-family);
}
body {
font-family: var(--font-family);
color: var(--primary-text-color);
font-size: 20px;
}
.main-background {
width: 650px;
background: var(--default-background);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.main-background-mask {
padding: 16px;
background-color: var(--background-mask-color);
}
.main {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.card {
border-radius: 8px;
padding: 16px;
background: var(--card-background-color);
box-shadow: var(--default-box-shadow);
backdrop-filter: blur(2px);
overflow: hidden;
}
.splitter > *:not(:first-child) {
margin-top: 8px;
padding-top: 8px;
border-top: 2px solid var(--segment-color);
}
.align-right {
text-align: right;
}
/* Span Label */
span.label {
padding: 2px 4px;
border-radius: 4px;
box-shadow: var(--default-box-shadow);
background-color: var(--background-color);
color: var(--secondary-text-color);
}
.label-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.label-container > * {
margin-right: 2px;
margin-bottom: 2px;
}
/* Account */
.account {
display: flex;
flex-direction: row;
}
.account .avatar {
width: 125px;
height: 125px;
border-radius: 50%;
box-shadow: var(--default-box-shadow);
background-color: var(--card-background-color);
}
.account .description {
margin-left: 16px;
display: flex;
flex-direction: column;
justify-content: center;
}
.account .description .nickname {
font-size: 36px;
font-weight: bold;
margin-bottom: 8px;
word-break: break-word;
line-height: 1.1;
}
/* Donut Chart */
.donut-chart {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.donut-chart .chart-wrapper,
.donut-chart .chart,
.donut-chart .shadow {
width: 150px;
height: 150px;
}
.donut-chart .chart-wrapper {
position: relative;
}
.donut-chart .chart {
transform: rotate(-90deg);
}
.donut-chart .chart .empty,
.donut-chart .chart .slice {
fill: transparent;
stroke-width: 15px;
}
.donut-chart .chart .empty {
stroke: var(--label-gray-bg-color);
}
.donut-chart .chart .slice {
stroke: var(--background-color);
stroke-dasharray: calc((67.5px * 2) * 3.1415926);
stroke-dashoffset: calc(
(67.5px * 2) * 3.1415926 / 360 * (360 - (360 * var(--percent)))
);
}
.donut-chart .shadow {
position: absolute;
top: 0;
border-radius: 50%;
box-shadow: var(--default-box-shadow);
}
.donut-chart .label {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.donut-chart .label,
.donut-chart .title {
font-size: 32px;
font-weight: bold;
text-wrap: nowrap;
white-space: nowrap;
}
.donut-chart .desc {
font-size: 12px;
word-break: break-word;
color: var(--secondary-text-color);
}
/* Progress Bar */
.progress-bar {
position: relative;
border-radius: 4px;
overflow: hidden;
box-shadow: var(--default-box-shadow);
}
.progress-bar .background {
position: absolute;
width: 100%;
height: 100%;
background-color: var(--label-gray-bg-color);
}
.progress-bar .progress {
position: absolute;
height: 100%;
background-color: var(--background-color);
}
.progress-bar .label {
position: relative;
z-index: 1;
text-align: center;
}
/* #### Split Line #### */
/* Card Header */
.card.header {
display: flex;
flex-direction: column;
}
.card.header .label-container {
font-size: 16px;
}
.card.header .extra > * {
flex-grow: 1;
text-align: center;
}
/* Donut Chart Line */
.donut-chart-line {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
align-items: start;
}
/* List Grid */
.list-grid {
display: grid;
gap: 4px;
align-items: center;
}
.list-grid.disk-usage {
grid-template-columns: auto minmax(180px, 100%) auto;
}
.list-grid.disk-io,
.list-grid.network-io,
.list-grid.network-connection-test,
.list-grid.process-usage {
grid-template-columns: minmax(0, 100%) auto auto auto auto auto;
}
.list-grid.network-connection-test {
grid-template-columns: minmax(0, 100%) auto auto auto;
}
.list-grid.network-connection-test .error {
grid-column-end: span 3;
text-align: right;
}
/* Footer */
.footer {
font-size: 14px;
text-align: center;
color: var(--primary-text-color);
text-shadow: var(--default-box-shadow);
}
@@ -0,0 +1,3 @@
.card {
backdrop-filter: none;
}
@@ -0,0 +1,5 @@
.card,
span.label,
.progress-bar {
border-radius: 0;
}
@@ -0,0 +1,3 @@
* {
--default-box-shadow: none;
}
@@ -0,0 +1,23 @@
* {
--primary-text-color: #d0d6e2;
--secondary-text-color: #abb2bf;
--default-background: #d0d6e2aa;
--background-mask-color: #282c3455;
--card-background-color: #282c3488;
--default-box-shadow: 2px 2px 6px #abb2bf66;
--label-default-bg-color: #282c34aa;
--label-red-bg-color: #e06c75aa;
--label-orange-bg-color: #d19a66aa;
--label-yellow-bg-color: #e5c07baa;
--label-green-bg-color: #98c379aa;
--label-cyan-bg-color: #56b6c2aa;
--label-blue-bg-color: #61afefaa;
--label-purple-bg-color: #c678ddaa;
--label-gray-bg-color: #737c8caa;
--label-black-bg-color: #d0d6e2aa;
--label-black-text-color: #282c34;
--segment-color: #5c6370;
}
@@ -0,0 +1,44 @@
{% from 'macros.html.jinja' import header, cpu_mem, disk, network, process, footer %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/default/res/css/index.css" />
{% for css in config.ps_default_additional_css -%}
<link rel="stylesheet" href="{{ css }}" />{% endfor %}
</head>
<body>
<div class="main-background" data-background-image="/api/background">
<div class="main-background-mask">
<div class="main">
{% for name in config.ps_default_components %}
{% if name == "header" %}
{{ header(d) }}
{% elif name == "cpu_mem" %}
{{ cpu_mem(d) }}
{% elif name == "disk" %}
{{ disk(d) }}
{% elif name == "network" %}
{{ network(d) }}
{% elif name == "process" %}
{{ process(d) }}
{% elif name == "footer" %}
{{ footer(d) }}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</body>
<script src="/js/init-global.js"></script>
<script src="/js/lazy-load.js"></script>
{% for script in config.ps_default_additional_script -%}
<script src="{{ script }}"></script>{% endfor %}
<script src="/js/load-plugin.js"></script>
</html>
@@ -0,0 +1,142 @@
{% macro donut_chart(percent, title, caption) %}
<div class="donut-chart">
<div class="chart-wrapper">
<svg class="chart" style="{% if percent != None %}--percent: {{ percent / 100 }}{% endif %}">
<circle class="empty" cx="50%" cy="50%" r="67.5px" />
{% if percent != None %}
<circle class="slice {{ percent | percent_to_color }}" cx="50%" cy="50%" r="67.5px" />
{% endif %}
</svg>
<div class="shadow"></div>
<div class="label">
{%- if percent == None %}未部署
{%- else %}{{ '{0:.0f}%'.format(percent) }}{% endif -%}
</div>
</div>
<div class="title">{{ title }}</div>
<div class="desc">{{ caption | br }}</div>
</div>
{% endmacro %}
{% macro header(d) %}
<div class="card header splitter">
{% for info in d.bots %}
<div class="account">
<img class="avatar" data-src="/api/bot_avatar/{{ info.self_id }}" />
<div class="description">
<div class="nickname">{{ info.nick }}</div>
<div class="status label-container">
<span class="label purple">{{ info.adapter }}</span>
<span class="label green">Bot已连接 {{ info.bot_connected }}</span>
<span class="label blue">收 {{ info.msg_rec }}</span>
<span class="label orange">发 {{ info.msg_sent }}</span>
</div>
</div>
</div>
{% endfor %}
<div class="extra label-container">
<span class="label gray">NoneBot运行 {{ d.nonebot_run_time }}</span>
<span class="label gray">系统运行 {{ d.system_run_time }}</span>
</div>
</div>
{% endmacro %}
{% macro cpu_mem(d) %}
{% if d.cpu_count %}{% set count = d.cpu_count %}{% else %}{% set count = '??' %}{% endif %}
{% if d.cpu_count_logical %}{% set logical = d.cpu_count_logical %}{% else %}{% set logical = '??' %}{% endif %}
{% set freq = d.cpu_freq | format_cpu_freq %}
{% set ram_used = d.memory_stat.used | auto_convert_unit %}
{% set ram_total = d.memory_stat.total | auto_convert_unit %}
{% set swap_used = d.swap_stat.used | auto_convert_unit %}
{% set swap_total = d.swap_stat.total | auto_convert_unit %}
<div class="card cpu-mem-usage donut-chart-line">
{{ donut_chart(d.cpu_percent, "CPU", "{}核 {}线程 {}\n{}".format(count, logical, freq, d.cpu_brand)) }}
{{ donut_chart(d.memory_stat.percent, "RAM", "{} / {}").format(ram_used, ram_total) }}
{{ donut_chart(d.swap_stat.percent, "SWAP", "{} / {}").format(swap_used, swap_total) }}
</div>
{% endmacro %}
{% macro disk(d) %}
<div class="card disk-info splitter">
<div class="list-grid disk-usage">
{% for it in d.disk_usage %}
<div>{{ it.name }}</div>
<div class="progress-bar">
<div class="background"></div>
{% if it.exception %}
<div class="label">{{ it.exception }}</div>
{% else %}
<div class="progress {{ it.percent | percent_to_color }}" style="width: {{ it.percent }}%"></div>
<div class="label">{{ it.used | auto_convert_unit }} / {{ it.total | auto_convert_unit }}</div>
{% endif %}
</div>
<div class="align-right">
{%- if it.percent %}{{ '{0:.1f}%'.format(it.percent) }}
{%- else %}??.?%{% endif -%}
</div>
{% endfor %}
</div>
{% if d.disk_io -%}
<div class="list-grid disk-io">
{% for it in d.disk_io %}
<div>{{ it.name }}</div>
<div>读</div>
<div class="align-right">{{ it.read | auto_convert_unit(suffix='/s') }}</div>
<div>|</div>
<div>写</div>
<div class="align-right">{{ it.write | auto_convert_unit(suffix='/s') }}</div>
{% endfor %}
</div>
{%- endif %}
</div>
{% endmacro %}
{% macro network(d) %}
<div class="card network-info splitter">
<div class="list-grid network-io">
{% for it in d.network_io or [] %}
<div>{{ it.name }}</div>
<div>↑</div>
<div class="align-right">{{ it.sent | auto_convert_unit(suffix='/s') }}</div>
<div>|</div>
<div>↓</div>
<div class="align-right">{{ it.recv | auto_convert_unit(suffix='/s') }}</div>
{% endfor %}
</div>
<div class="list-grid network-connection-test">
{% for it in d.network_connection %}
<div>{{ it.name }}</div>
{% if it.error %}
<div class="error">{{ it.error }}</div>
{% else %}
<div>{{ it.status }} {{ it.reason }}</div>
<div>|</div>
<div>{{ '{0:.2f}ms'.format(it.delay) }}</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endmacro %}
{% macro process(d) %}
<div class="card process-info splitter">
<div class="list-grid process-usage">
{% for it in d.process_status or [] %}
<div>{{ it.name }}</div>
<div>CPU</div>
<div class="align-right">{{ '{0:.1f}%'.format(it.cpu) }}</div>
<div>|</div>
<div>MEM</div>
<div class="align-right">{{ it.mem | auto_convert_unit }}</div>
{% endfor %}
</div>
</div>
{% endmacro %}
{% macro footer(d) %}
<div class="footer">
NoneBot {{ d.nonebot_version }} × PicStatus {{ d.ps_version }} | {{ d.time }}<br />
{{ d.python_version }} | {{ d.system_name }}
</div>
{% endmacro %}
@@ -0,0 +1,142 @@
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar
from urllib.parse import urlencode
from cookit import auto_convert_byte
from cookit.jinja import all_filters
from cookit.jinja.filters import cookit_global_filter
from cookit.pw import CKRouterFunc, RouterGroup, make_real_path_router
from cookit.pw.loguru import log_router_err
from nonebot import logger
from yarl import URL
from ..config import DEFAULT_AVATAR_PATH, config
from ..misc_statistics import bot_avatar_cache
from ..util import format_cpu_freq
if TYPE_CHECKING:
import jinja2
from playwright.async_api import Request, Route
from ..bg_provider import BgBytesData
TC = TypeVar("TC", bound=Callable[..., Any])
ROOT_PATH = Path(__file__).parent.parent
RES_PATH = ROOT_PATH / "res"
ROUTE_URL = "http://picstatus.nonebot"
RES_LOCATION_MAP = {
"": RES_PATH,
}
# region pw
base_router_group = RouterGroup()
def resolve_file_url(
path: str,
additional_locations: dict[str, Path] | None = None,
) -> str:
if path.startswith("res:"):
path = path[4:].lstrip("/")
locations = {**RES_LOCATION_MAP, **(additional_locations or {})}
for pfx, loc in locations.items():
if (loc / path).exists():
return f"/{pfx}/{path}"
raise ValueError(f"Cannot resolve builtin resource `{path}`")
params = urlencode({"path": path})
return f"/api/local_file?{params}"
def make_file_router(
query_name: str | None = None,
base_path: Path | None = None,
prefix_omit: str = "",
) -> CKRouterFunc:
@log_router_err()
@make_real_path_router
async def router(request: "Request", **_):
url = URL(request.url)
query_path = url.query.get(query_name, "") if query_name else url.path[1:]
if prefix_omit and query_path.startswith(prefix_omit):
query_path = query_path[len(prefix_omit) :]
path = Path((base_path / query_path) if base_path else query_path)
logger.debug(f"Associated file `{path}`")
return path
return router
@base_router_group.router(f"{ROUTE_URL}/api/bot_avatar/*")
@log_router_err()
async def _(route: "Route", request: "Request", **_):
url = URL(request.url)
self_id = url.parts[-1]
if self_id in bot_avatar_cache:
await route.fulfill(body=bot_avatar_cache[self_id])
return
data = (
config.ps_default_avatar
if config.ps_default_avatar.is_file()
else DEFAULT_AVATAR_PATH
).read_bytes()
await route.fulfill(body=data)
base_router_group.router(f"{ROUTE_URL}/api/local_file*")(
make_file_router(query_name="path", base_path=None),
)
base_router_group.router(f"{ROUTE_URL}/**/*", priority=100)(
make_file_router(query_name=None, base_path=RES_PATH),
)
def add_root_router(router_group: RouterGroup, html: str):
@router_group.router(f"{ROUTE_URL}/")
@log_router_err()
async def _(route: "Route", **_):
await route.fulfill(content_type="text/html", body=html)
def add_background_router(router_group: RouterGroup, bg: "BgBytesData"):
@router_group.router(f"{ROUTE_URL}/api/background")
@log_router_err()
async def _(route: "Route", **_):
await route.fulfill(content_type=bg.mime, body=bg.data)
# endregion
# region jinja
jinja_filter = type(cookit_global_filter)(all_filters.copy())
def register_global_filter_to(env: "jinja2.Environment"):
env.filters.update(jinja_filter.data)
jinja_filter(format_cpu_freq)
@jinja_filter
def percent_to_color(percent: float) -> str:
if percent < 70:
return "prog-low"
if percent < 90:
return "prog-medium"
return "prog-high"
@jinja_filter
def auto_convert_unit(value: float, **kw) -> str:
return auto_convert_byte(value=value, with_space=False, **kw)
# endregion
@@ -0,0 +1,39 @@
import re
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING
from cookit import DebugFileWriter, auto_convert_unit, format_timedelta
if TYPE_CHECKING:
from .collectors.cpu import CpuFreq
format_time_delta_ps = partial(format_timedelta, day_divider=" ", day_suffix="天")
def match_list_regexp(reg_list: list[str], txt: str) -> re.Match | None:
return next((match for r in reg_list if (match := re.search(r, txt))), None)
def format_freq(value: float) -> str:
"""将 MHz 频率格式化为 MHz/GHz(十进制 1000 换算)"""
return auto_convert_unit(
units=("MHz", "GHz", "THz"),
multiplier=1000,
unit_threshold=1000,
value=value,
with_space=False,
)
def format_cpu_freq(freq: "CpuFreq") -> str:
if not freq.current and not freq.max:
return "主频未知"
current = freq.current or freq.max or 0.0
max_freq = freq.max or 0.0
if not max_freq or max_freq == current:
return format_freq(current)
return f"{format_freq(current)} / {format_freq(max_freq)}"
debug = DebugFileWriter(Path(__file__).resolve().parents[2] / "logs", "picstatus")