diff --git a/poetry.lock b/poetry.lock index cdd9bbc..c4c8490 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1297,6 +1297,25 @@ type = "legacy" url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" reference = "tsinghua" +[[package]] +name = "humanize" +version = "4.9.0" +description = "Python humanize utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "humanize-4.9.0-py3-none-any.whl", hash = "sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16"}, + {file = "humanize-4.9.0.tar.gz", hash = "sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa"}, +] + +[package.extras] +tests = ["freezegun", "pytest", "pytest-cov"] + +[package.source] +type = "legacy" +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" +reference = "tsinghua" + [[package]] name = "hyperframe" version = "6.0.1" @@ -2563,6 +2582,39 @@ type = "legacy" url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" reference = "tsinghua" +[[package]] +name = "psutil" +version = "5.9.7" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "psutil-5.9.7-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0bd41bf2d1463dfa535942b2a8f0e958acf6607ac0be52265ab31f7923bcd5e6"}, + {file = "psutil-5.9.7-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:5794944462509e49d4d458f4dbfb92c47539e7d8d15c796f141f474010084056"}, + {file = "psutil-5.9.7-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:fe361f743cb3389b8efda21980d93eb55c1f1e3898269bc9a2a1d0bb7b1f6508"}, + {file = "psutil-5.9.7-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:e469990e28f1ad738f65a42dcfc17adaed9d0f325d55047593cb9033a0ab63df"}, + {file = "psutil-5.9.7-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:3c4747a3e2ead1589e647e64aad601981f01b68f9398ddf94d01e3dc0d1e57c7"}, + {file = "psutil-5.9.7-cp27-none-win32.whl", hash = "sha256:1d4bc4a0148fdd7fd8f38e0498639ae128e64538faa507df25a20f8f7fb2341c"}, + {file = "psutil-5.9.7-cp27-none-win_amd64.whl", hash = "sha256:4c03362e280d06bbbfcd52f29acd79c733e0af33d707c54255d21029b8b32ba6"}, + {file = "psutil-5.9.7-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ea36cc62e69a13ec52b2f625c27527f6e4479bca2b340b7a452af55b34fcbe2e"}, + {file = "psutil-5.9.7-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1132704b876e58d277168cd729d64750633d5ff0183acf5b3c986b8466cd0284"}, + {file = "psutil-5.9.7-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8b7f07948f1304497ce4f4684881250cd859b16d06a1dc4d7941eeb6233bfe"}, + {file = "psutil-5.9.7-cp36-cp36m-win32.whl", hash = "sha256:b27f8fdb190c8c03914f908a4555159327d7481dac2f01008d483137ef3311a9"}, + {file = "psutil-5.9.7-cp36-cp36m-win_amd64.whl", hash = "sha256:44969859757f4d8f2a9bd5b76eba8c3099a2c8cf3992ff62144061e39ba8568e"}, + {file = "psutil-5.9.7-cp37-abi3-win32.whl", hash = "sha256:c727ca5a9b2dd5193b8644b9f0c883d54f1248310023b5ad3e92036c5e2ada68"}, + {file = "psutil-5.9.7-cp37-abi3-win_amd64.whl", hash = "sha256:f37f87e4d73b79e6c5e749440c3113b81d1ee7d26f21c19c47371ddea834f414"}, + {file = "psutil-5.9.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:032f4f2c909818c86cea4fe2cc407f1c0f0cde8e6c6d702b28b8ce0c0d143340"}, + {file = "psutil-5.9.7.tar.gz", hash = "sha256:3f02134e82cfb5d089fddf20bb2e03fd5cd52395321d1c8458a9e58500ff417c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[package.source] +type = "legacy" +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" +reference = "tsinghua" + [[package]] name = "pydantic" version = "1.10.13" @@ -3937,4 +3989,4 @@ reference = "tsinghua" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "789de25d9e3b491d9969ade6ad42a23037f98ffd05a42edb775c57104ffe9cb0" +content-hash = "902971e6106949c55e9f19b1960365979ce877f894370ff3eee4b9c79a0a772c" diff --git a/pyproject.toml b/pyproject.toml index c265d7e..29def74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ nonebot-plugin-eventexpiry = "^0.1.1" imageio = "^2.33.1" pillow = "^9.0.0" nonebot-plugin-datastore = "^1.1.2" +humanize = "^4.9.0" +psutil = "^5.9.7" [build-system] diff --git a/src/plugins/nonebot_plugin_status/__init__.py b/src/plugins/nonebot_plugin_status/__init__.py new file mode 100644 index 0000000..c7638c5 --- /dev/null +++ b/src/plugins/nonebot_plugin_status/__init__.py @@ -0,0 +1,120 @@ +""" +@Author : yanyongyu +@Date : 2020-09-18 00:00:13 +@LastEditors : yanyongyu +@LastEditTime : 2023-12-01 10:41:28 +@Description : Status plugin +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + +import inspect +import contextlib +from typing import Any, Dict + +from jinja2 import Environment +from nonebot import get_driver +from nonebot.matcher import Matcher +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from jinja2.meta import find_undeclared_variables + +from .config import Config +from .helpers import humanize_date, relative_time, humanize_delta +from .data_source import ( + get_uptime, + get_cpu_status, + get_disk_usage, + per_cpu_status, + get_swap_status, + get_memory_status, + get_bot_connect_time, + get_nonebot_run_time, +) + +__plugin_meta__ = PluginMetadata( + name="服务器状态查看", + description="通过戳一戳获取服务器状态", + usage=( + "通过QQ私聊戳一戳或拍一拍头像获取机器人服务器状态\n" + "或者通过发送指令 `status/状态` 获取机器人服务器状态\n" + "可以通过配置文件修改服务器状态模板" + ), + type="application", + homepage="https://github.com/cscs181/QQ-GitHub-Bot/tree/master/src/plugins/nonebot_plugin_status", + config=Config, + supported_adapters=None, +) + +global_config = get_driver().config +status_config = Config.parse_obj(global_config) +status_permission = (status_config.server_status_only_superusers or None) and SUPERUSER + +_ev = Environment( + trim_blocks=True, lstrip_blocks=True, autoescape=False, enable_async=True +) +_ev.globals["relative_time"] = relative_time +_ev.filters["relative_time"] = relative_time +_ev.filters["humanize_date"] = humanize_date +_ev.globals["humanize_date"] = humanize_date +_ev.filters["humanize_delta"] = humanize_delta +_ev.globals["humanize_delta"] = humanize_delta + +_t_ast = _ev.parse(status_config.server_status_template) +_t_vars = find_undeclared_variables(_t_ast) +_t = _ev.from_string(_t_ast) + +KNOWN_VARS = { + "cpu_usage": get_cpu_status, + "per_cpu_usage": per_cpu_status, + "memory_usage": get_memory_status, + "swap_usage": get_swap_status, + "disk_usage": get_disk_usage, + "uptime": get_uptime, + "runtime": get_nonebot_run_time, + "bot_connect_time": get_bot_connect_time, +} +"""Available variables for template rendering.""" + + +if not set(_t_vars).issubset(KNOWN_VARS): + raise ValueError( + "Unknown variables in status template:" + f" {', '.join(set(_t_vars) - set(KNOWN_VARS))}" + ) + + +async def _solve_required_vars() -> Dict[str, Any]: + """Solve required variables for template rendering.""" + return ( + { + k: await v() if inspect.iscoroutinefunction(v) else v() + for k, v in KNOWN_VARS.items() + if k in _t_vars + } + if status_config.server_status_truncate + else { + k: await v() if inspect.iscoroutinefunction(v) else v() + for k, v in KNOWN_VARS.items() + } + ) + + +async def render_template() -> str: + """Render status template with required variables.""" + message = await _t.render_async(**(await _solve_required_vars())) + return message.strip("\n") + + +async def server_status(matcher: Matcher): + """Server status matcher handler.""" + await matcher.send(message=await render_template()) + + +from . import common as common # noqa: E402 + +with contextlib.suppress(ImportError): + import nonebot.adapters.onebot.v11 # noqa: F401 + + from . import onebot_v11 as onebot_v11 diff --git a/src/plugins/nonebot_plugin_status/common.py b/src/plugins/nonebot_plugin_status/common.py new file mode 100644 index 0000000..c56dc65 --- /dev/null +++ b/src/plugins/nonebot_plugin_status/common.py @@ -0,0 +1,24 @@ +""" +@Author : yanyongyu +@Date : 2020-09-18 00:00:13 +@LastEditors : yanyongyu +@LastEditTime : 2023-03-30 18:26:14 +@Description : Common text matcher for status plugin +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + +from nonebot import on_command + +from . import server_status, status_config, status_permission + +if status_config.server_status_enabled: + command = on_command( + "status", + aliases={"状态"}, + permission=status_permission, + priority=10, + handlers=[server_status], + ) + """`status`/`状态` command matcher""" diff --git a/src/plugins/nonebot_plugin_status/config.py b/src/plugins/nonebot_plugin_status/config.py new file mode 100644 index 0000000..fe70b9f --- /dev/null +++ b/src/plugins/nonebot_plugin_status/config.py @@ -0,0 +1,68 @@ +""" +@Author : yanyongyu +@Date : 2020-10-04 16:32:00 +@LastEditors : yanyongyu +@LastEditTime : 2023-03-30 18:17:09 +@Description : Config for status plugin +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + + +from pydantic import Extra, BaseModel + +CPU_TEMPLATE = r"CPU: {{ '%02d' % cpu_usage }}%" +"""Default CPU status template.""" + +# PER_CPU_TEMPLATE = ( +# "CPU:\n" +# "{%- for core in per_cpu_usage %}\n" +# " core{{ loop.index }}: {{ '%02d' % core }}%\n" +# "{%- endfor %}" +# ) + +MEMORY_TEMPLATE = r"Memory: {{ '%02d' % memory_usage.percent }}%" +"""Default memory status template.""" + +SWAP_TEMPLATE = ( + r"{% if swap_usage.total %}Swap: {{ '%02d' % swap_usage.percent }}%{% endif +%}" +) +"""Default swap status template.""" + +DISK_TEMPLATE = ( + "Disk:\n" + "{% for name, usage in disk_usage.items() %}\n" + " {{ name }}: {{ '%02d' % usage.percent }}%\n" + "{% endfor %}" +) +"""Default disk status template.""" + +UPTIME_TEMPLATE = "Uptime: {{ uptime | relative_time | humanize_delta }}" +"""Default uptime status template.""" + +RUNTIME_TEMPLATE = "Runtime: {{ runtime | relative_time | humanize_delta }}" +"""Default runtime status template.""" + + +class Config(BaseModel, extra=Extra.ignore): + server_status_enabled: bool = True + """Whether to enable the server status commands.""" + server_status_truncate: bool = True + """Whether to render the status template with used variables only.""" + server_status_only_superusers: bool = True + """Whether to allow only superusers to use the status commands.""" + + server_status_template: str = "\n".join( + (CPU_TEMPLATE, MEMORY_TEMPLATE, RUNTIME_TEMPLATE, SWAP_TEMPLATE, DISK_TEMPLATE) + ) + """Default server status template. + + Including: + + - CPU usage + - Memory usage + - Runtime + - Swap usage + - Disk usage + """ diff --git a/src/plugins/nonebot_plugin_status/data_source.py b/src/plugins/nonebot_plugin_status/data_source.py new file mode 100644 index 0000000..06f734e --- /dev/null +++ b/src/plugins/nonebot_plugin_status/data_source.py @@ -0,0 +1,104 @@ +""" +@Author : yanyongyu +@Date : 2020-09-18 00:15:21 +@LastEditors : yanyongyu +@LastEditTime : 2023-06-05 16:40:35 +@Description : Getting status of the bot and the mechine +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + +import asyncio +from datetime import datetime +from typing import TYPE_CHECKING, Dict, List, Optional + +import psutil +from nonebot.adapters import Bot +from nonebot import logger, get_driver + +if TYPE_CHECKING: + from psutil._common import sdiskusage + +CURRENT_TIMEZONE = datetime.now().astimezone().tzinfo + +driver = get_driver() + +# bot status +_nonebot_run_time: datetime +_bot_connect_time: Dict[str, datetime] = {} + + +@driver.on_startup +async def _(): + global _nonebot_run_time + _nonebot_run_time = datetime.now(CURRENT_TIMEZONE) + + +@driver.on_bot_connect +async def _(bot: Bot): + _bot_connect_time[bot.self_id] = datetime.now(CURRENT_TIMEZONE) + + +@driver.on_bot_disconnect +async def _(bot: Bot): + _bot_connect_time.pop(bot.self_id, None) + + +def get_nonebot_run_time() -> datetime: + """Get the time when NoneBot started running.""" + try: + return _nonebot_run_time + except NameError: + raise RuntimeError("NoneBot not running!") from None + + +def get_bot_connect_time() -> Dict[str, datetime]: + """Get the time when the bot connected to the server.""" + return _bot_connect_time + + +async def get_cpu_status() -> float: + """Get the CPU usage status.""" + psutil.cpu_percent() + await asyncio.sleep(0.5) + return psutil.cpu_percent() + + +async def per_cpu_status() -> List[float]: + """Get the CPU usage status of each core.""" + psutil.cpu_percent(percpu=True) + await asyncio.sleep(0.5) + return psutil.cpu_percent(percpu=True) # type: ignore + + +def get_memory_status(): + """Get the memory usage status.""" + return psutil.virtual_memory() + + +def get_swap_status(): + """Get the swap usage status.""" + return psutil.swap_memory() + + +def _get_disk_usage(path: str) -> Optional["sdiskusage"]: + try: + return psutil.disk_usage(path) + except Exception as e: + logger.warning(f"Could not get disk usage for {path}: {e!r}") + + +def get_disk_usage() -> Dict[str, "sdiskusage"]: + """Get the disk usage status.""" + disk_parts = psutil.disk_partitions() + return { + d.mountpoint: usage + for d in disk_parts + if (usage := _get_disk_usage(d.mountpoint)) + } + + +def get_uptime() -> datetime: + """Get the uptime of the mechine.""" + return datetime.fromtimestamp(psutil.boot_time(), tz=CURRENT_TIMEZONE) diff --git a/src/plugins/nonebot_plugin_status/helpers.py b/src/plugins/nonebot_plugin_status/helpers.py new file mode 100644 index 0000000..5ed89f3 --- /dev/null +++ b/src/plugins/nonebot_plugin_status/helpers.py @@ -0,0 +1,27 @@ +""" +@Author : yanyongyu +@Date : 2022-10-15 08:08:41 +@LastEditors : yanyongyu +@LastEditTime : 2023-03-30 18:24:49 +@Description : Template rendering helpers +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + + +from datetime import datetime, timedelta + +import humanize + + +def relative_time(time: datetime) -> timedelta: + return datetime.now().astimezone() - time.astimezone() + + +def humanize_date(time: datetime) -> str: + return humanize.naturaldate(time.astimezone()) + + +def humanize_delta(delta: timedelta) -> str: + return humanize.precisedelta(delta, minimum_unit="minutes") diff --git a/src/plugins/nonebot_plugin_status/onebot_v11.py b/src/plugins/nonebot_plugin_status/onebot_v11.py new file mode 100644 index 0000000..23662b1 --- /dev/null +++ b/src/plugins/nonebot_plugin_status/onebot_v11.py @@ -0,0 +1,48 @@ +""" +@Author : yanyongyu +@Date : 2022-09-02 11:35:48 +@LastEditors : yanyongyu +@LastEditTime : 2023-03-30 18:25:12 +@Description : OneBot v11 matchers for status plugin +@GitHub : https://github.com/yanyongyu +""" + +__author__ = "yanyongyu" + +from nonebot.rule import to_me +from nonebot import on_type, on_message +from nonebot.adapters.onebot.v11 import PokeNotifyEvent, PrivateMessageEvent + +from . import server_status, status_config, status_permission + +if status_config.server_status_enabled: + group_poke = on_type( + (PokeNotifyEvent,), + rule=to_me(), + permission=status_permission, + priority=10, + block=True, + handlers=[server_status], + ) + """Poke notify matcher. + + 双击头像拍一拍 + """ + + +async def _poke(event: PrivateMessageEvent) -> bool: + return event.sub_type == "friend" and event.message[0].type == "poke" + + +if status_config.server_status_enabled: + poke = on_message( + _poke, + permission=status_permission, + priority=10, + block=True, + handlers=[server_status], + ) + """Poke message matcher. + + 私聊发送戳一戳 + """