diff --git a/.github/ISSUE_TEMPLATE/问题反馈.md b/.github/ISSUE_TEMPLATE/问题反馈.md deleted file mode 100644 index 9c95983..0000000 --- a/.github/ISSUE_TEMPLATE/问题反馈.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: 问题反馈 -about: 反馈你在使用轻雪中遇到的问题 -title: '' -labels: '' -assignees: '' - ---- - -# 问题反馈 - -## **请确保** - -- 已认真阅读[文档]("https://bot.liteyuki.icu"),该问题不是文档提及的或你自己操作不当造成的 -- 你的问题是在最新版本的代码上测试的 -- 请勿重复提交相同或类似的issue - - -## **描述问题** - -请在此简单描述问题 - - - -## **如何复现** - -请阐述一下如何重现这个问题 -### 预期 - -描述你期望发生的事情 - -### 实际 - -描述实际发生的事情 - - - -## **日志或截图** -``` -日志内容 -``` - - -## **设备信息** -- **系统**: [例如 Ubuntu 22.04] -- **CPU**: [例如 Intel i7-7700K] -- **内存**: [例如 16GB] -- **Python**: [例如CPython 3.10.7] - - -**补充内容** - -可选,推荐提供`pip freeze`的输出,以及其他相关信息,以及你的建议 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 121d32a..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/workflows/ISSUE_TEMPLATE.md b/.github/workflows/ISSUE_TEMPLATE.md deleted file mode 100644 index b91c501..0000000 --- a/.github/workflows/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,44 +0,0 @@ -# 问题反馈 - -## **请确保** - -- 已认真阅读[文档]("https://bot.liteyuki.icu"),该问题不是文档提及的或你自己操作不当造成的 -- 你的问题是在最新版本的代码上测试的 -- 请勿重复提交相同或类似的issue - - -## **描述问题** - -请在此简单描述问题 - - - -## **如何复现** - -请阐述一下如何重现这个问题 -### 预期 - -描述你期望发生的事情 - -### 实际 - -描述实际发生的事情 - - - -## **日志或截图** -``` -日志内容 -``` - - -## **设备信息** -- **系统**: [例如 Ubuntu 22.04] -- **CPU**: [例如 Intel i7-7700K] -- **内存**: [例如 16GB] -- **Python**: [例如CPython 3.10.7] - - -**补充内容** - -可选,推荐提供`pip freeze`的输出,以及其他相关信息,以及你的建议 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 54c9858..0000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,49 +0,0 @@ - -name: 部署文档 - -on: - push: - branches: - # 确保这是你正在使用的分支名称 - - main - -permissions: - contents: write - -jobs: - deploy-gh-pages: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - # 如果你文档需要 Git 子模块,取消注释下一行 -# submodules: true - - - name: 安装 pnpm - uses: pnpm/action-setup@v2 - with: - run_install: true - version: 8 - - - - name: 设置 Node.js - run: |- - cd docs - pnpm install - - - name: 构建文档 - env: - NODE_OPTIONS: --max_old_space_size=8192 - run: |- - cd docs - pnpm run docs:build - > .vuepress/dist/.nojekyll - - - name: 部署文档 - uses: JamesIves/github-pages-deploy-action@v4 - with: - # 这是文档部署到的分支名称 - branch: gh-pages - folder: docs/.vuepress/dist diff --git a/.gitignore b/.gitignore index a640ab8..86a5a45 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ __pycache__/ *.pyd *.pyw /plugins/ + +#config +/config/ _config.yml config.yml config.example.yml @@ -29,11 +32,11 @@ src/nonebot_plugins/dislink_plugin_ccnd .github # pyproject.toml -test.py -line_count.py +# mypy mypy.ini # nuitka +compile.bat main.build/ main.dist/ main.exe @@ -46,3 +49,23 @@ prompt.txt # js **/echarts.js .env + +# pdm +.pdm-python +.pdm-build +dist + +doc + +mkdoc2.py +result.json + +# litedoc +docs/zh/dev/api +docs/en/dev/api +mkdoc.bat + +# vitepress +docs/.vitepress/dist/ +docs/.vitepress/cache +docs/.vitepress/.temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 58839fe..fcd1d16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-bullseye +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.10-slim-bullseye ENV TZ Asia/Shanghai diff --git a/README.md b/README.md index a6c2fd1..b02452d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![][Python3.10+]][python-link] [![][Usage]][usage-link] -- 基于[Nonebot2](https://github.com/nonebot/nonebot2),有良好的生态支持 +- 原生支持与任意 `Python`Bot 框架互联,有良好的生态支持 - 开箱即用,无需复杂配置 - 集成包管理器,支持一键安装插件 - 支持 OneBot 标准通信但不限于此 @@ -27,10 +27,7 @@ ### 感谢 -- 感谢[NoneBot2](https://nonebot.dev)提供的框架支持 -- 感谢[nonebot-plugin-htmlrender](https://github.com/kexue-z/nonebot-plugin-htmlrender)提供的渲染功能 -- 讨厌上述项目提供的**基于 Chromium 浏览器**的 HTML 渲染功能,就功能本身而言很好,但我讨厌一声不响在我电脑里装浏览器这个行为,虽然这并不妨碍我感谢之 ——金羿 -- 感谢[nonebot-plugin-alconna](https://github.com/ArcletProject/nonebot-plugin-alconna)提供的命令解析功能 +- 感谢所有贡献者们 - 十分感谢[神羽 SnowyKami](https://github.com/snowykami)提供的技术指导和服务器资源 - 特别感谢[云裳工作室](https://doc.ysmcc.cn/doc/1/)提供的服务器挂载 - 由衷感谢我在学习生活中遇到的所有朋友们,你们身为我生命中的一处景色,不断地推进我此生的进程。 diff --git a/clean_pycache.py b/clean_pycache.py new file mode 100644 index 0000000..eeb8efe --- /dev/null +++ b/clean_pycache.py @@ -0,0 +1,31 @@ +import shutil +import os +from rich.console import Console +from rich.progress import track + +console = Console() + + +def main(): + with console.status("正在搜寻可清理之文件"): + egg_info: list = [] + for file in os.listdir(): + if file.endswith(".egg-info"): + egg_info.append(file) + console.print(file) + + pycache: list = [] + for dirpath, dirnames, filenames in os.walk("./"): + for dirname in dirnames: + if dirname == "__pycache__": + pycache.append(fn := os.path.join(dirpath, dirname)) + console.print(fn) + for file in track( + ["build", "dist", "logs", "log", *egg_info, *pycache], description="正在清理" + ): + if os.path.isdir(file) and os.access(file, os.W_OK): + shutil.rmtree(file) + + +if __name__ == "__main__": + main() diff --git a/liteyuki/__init__.py b/liteyuki/__init__.py index 4ace66e..c96f3ed 100644 --- a/liteyuki/__init__.py +++ b/liteyuki/__init__.py @@ -1,11 +1,12 @@ from liteyuki.bot import ( LiteyukiBot, - get_bot + get_bot, + get_config, + get_config_with_compat ) from liteyuki.comm import ( Channel, - chan, Event ) @@ -15,7 +16,27 @@ from liteyuki.plugin import ( ) from liteyuki.log import ( - logger, - init_log - + init_log, + logger ) + +__all__ = [ + "LiteyukiBot", + "get_bot", + "get_config", + "get_config_with_compat", + "Channel", + "Event", + "load_plugin", + "load_plugins", + "init_log", + "logger", +] + +__version__ = "6.3.9" # 测试版本号 +# 6.3.9 +# 更改了on语法 + +# 6.3.8 +# 1. 初步添加对聊天的支持 +# 2. 优化了通道的性能 diff --git a/liteyuki/bot/__init__.py b/liteyuki/bot/__init__.py index 3f81fbf..0d0dc13 100644 --- a/liteyuki/bot/__init__.py +++ b/liteyuki/bot/__init__.py @@ -1,103 +1,102 @@ import asyncio +import atexit import os import platform +import signal import sys import threading import time from typing import Any, Optional -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer - -from liteyuki.bot.lifespan import LIFESPAN_FUNC, Lifespan -from liteyuki.core import IS_MAIN_PROCESS +from liteyuki.bot.lifespan import LIFESPAN_FUNC, Lifespan, PROCESS_LIFESPAN_FUNC +from liteyuki.comm.channel import get_channel from liteyuki.core.manager import ProcessManager -from liteyuki.core.spawn_process import mb_run, nb_run from liteyuki.log import init_log, logger -from liteyuki.plugin import load_plugins +from liteyuki.plugin import load_plugin +from liteyuki.utils import IS_MAIN_PROCESS -__all__ = ["LiteyukiBot", "get_bot"] +__all__ = [ + "LiteyukiBot", + "get_bot", + "get_config", + "get_config_with_compat", +] class LiteyukiBot: - def __init__(self, *args, **kwargs): + def __init__(self, **kwargs) -> None: + """ + 初始化轻雪实例 + Args: + **kwargs: 配置 + """ + """常规操作""" + print_logo() global _BOT_INSTANCE _BOT_INSTANCE = self # 引用 + + """配置""" self.config: dict[str, Any] = kwargs + + """初始化""" self.init(**self.config) # 初始化 + logger.info("尹灵温 正在初始化…") - self.lifespan: Lifespan = Lifespan() + """生命周期管理""" + self.lifespan = Lifespan() + self.process_manager: ProcessManager = ProcessManager(lifespan=self.lifespan) - self.process_manager: ProcessManager = ProcessManager(bot=self) + """事件循环""" self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) - self.loop_thread = threading.Thread(target=self.loop.run_forever, daemon=True) + self.stop_event = threading.Event() self.call_restart_count = 0 + """加载插件加载器""" + load_plugin("liteyuki.plugins.plugin_loader") # 加载轻雪插件 + + async def _run(self): + """ + 启动逻辑 + """ + await self.lifespan.before_start() # 启动前钩子 + await self.lifespan.after_start() # 启动后钩子 + await self.keep_alive() + def run(self): - load_plugins("liteyuki/plugins") # 加载轻雪插件 + """ + 外部启动接口 + """ + self.process_manager.start_all() + try: + asyncio.run(self._run()) + except KeyboardInterrupt: + logger.opt(colors=True).info("尹灵温 关闭中…") + self.stop() + logger.opt(colors=True).info("尹灵温 已关停") - self.loop_thread.start() # 启动事件循环 - asyncio.run(self.lifespan.before_start()) # 启动前钩子 - - self.process_manager.add_target("nonebot", nb_run, **self.config) - self.process_manager.start("nonebot") - - self.process_manager.add_target("melobot", mb_run, **self.config) - self.process_manager.start("melobot") - - asyncio.run(self.lifespan.after_start()) # 启动后钩子 - - self.start_watcher() # 启动文件监视器 - - def start_watcher(self): - if self.config.get("debug", False): - - src_directories = ( - "liteyuki", - "src/liteyuki_main", - "src/liteyuki_plugins", - "src/nonebot_plugins", - "src/utils", - ) - src_excludes_extensions = ("pyc",) - - logger.debug("轻雪重载 已启用,正在加载文件修改监测……") - restart = self.restart_process - - class CodeModifiedHandler(FileSystemEventHandler): - """ - Handler for code file changes - """ - - def on_modified(self, event): - if ( - event.src_path.endswith(src_excludes_extensions) - or event.is_directory - or "__pycache__" in event.src_path - ): - return - logger.info(f"文件 {event.src_path} 已修改,机器人自动重启……") - restart() - - code_modified_handler = CodeModifiedHandler() - - observer = Observer() - for directory in src_directories: - observer.schedule(code_modified_handler, directory, recursive=True) - observer.start() + async def keep_alive(self): + """ + 保持轻雪运行 + """ + logger.info("尹灵温 持续运行中…") + try: + while not self.stop_event.is_set(): + await asyncio.sleep(0.1) + except Exception: + logger.info("尹灵温 现退停…") + self.stop() def restart(self, delay: int = 0): """ 重启轻雪本体 - Returns: - + Args: + delay ([`int`](https%3A//docs.python.org/3/library/functions.html#int), optional): 延迟重启时间. Defaults to 0. """ - if self.call_restart_count < 1: executable = sys.executable args = sys.argv - logger.info("正在重启 尹灵温...") + logger.info("正在重启 尹灵温机器人框架") time.sleep(delay) if platform.system() == "Windows": cmd = "start" @@ -110,7 +109,9 @@ class LiteyukiBot: self.process_manager.terminate_all() # 进程退出后重启 threading.Thread( - target=os.system, args=(f"{cmd} {executable} {' '.join(args)}",) + target=os.system, + args=(f"{cmd} {executable} {' '.join(args)}",), + daemon=True, ).start() sys.exit(0) self.call_restart_count += 1 @@ -119,44 +120,46 @@ class LiteyukiBot: """ 停止轻雪 Args: - name: 进程名称, 默认为None, 所有进程 + name ([`Optional`](https%3A//docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)]): 进程名. Defaults to None. Returns: - """ - logger.info("Stopping LiteyukiBot...") - - self.loop.create_task(self.lifespan.before_shutdown()) # 重启前钩子 - self.loop.create_task(self.lifespan.before_shutdown()) # 停止前钩子 - - if name: - self.process_manager.terminate(name) + if name is not None: + chan_active = get_channel(f"{name}-active") + chan_active.send(1) else: - self.process_manager.terminate_all() + for process_name in self.process_manager.processes: + chan_active = get_channel(f"{process_name}-active") + chan_active.send(1) def init(self, *args, **kwargs): """ 初始化轻雪, 自动调用 - Returns: - + Args: + *args: 参数 + **kwargs: 关键字参数 """ - self.init_config() self.init_logger() def init_logger(self): - # 修改nonebot的日志配置 + """ + 初始化日志 + """ init_log(config=self.config) - def init_config(self): - pass + def stop(self): + """ + 停止轻雪 + """ + self.process_manager.terminate_all() + self.stop_event.set() - def on_before_start(self, func: LIFESPAN_FUNC): + def on_before_start(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: """ 注册启动前的函数 Args: - func: - + func ([`LIFESPAN_FUNC`](./lifespan#var-lifespan-func)): 生命周期函数 Returns: - + [`LIFESPAN_FUNC`](./lifespan#var-lifespan-func): 生命周期函数 """ return self.lifespan.on_before_start(func) @@ -164,81 +167,128 @@ class LiteyukiBot: """ 注册启动后的函数 Args: - func: - + func ([`LIFESPAN_FUNC`](./lifespan#var-lifespan-func)): 生命周期函数 Returns: - + [`LIFESPAN_FUNC`](./lifespan#var-lifespan-func): 生命周期函数 """ return self.lifespan.on_after_start(func) - def on_before_shutdown(self, func: LIFESPAN_FUNC): - """ - 注册停止前的函数,为子进程停止时调用 - Args: - func: - - Returns: - - """ - return self.lifespan.on_before_shutdown(func) - def on_after_shutdown(self, func: LIFESPAN_FUNC): """ 注册停止后的函数:未实现 Args: - func: - + func ([`LIFESPAN_FUNC`](./lifespan#var-lifespan-func)): 生命周期函数 Returns: - + [`LIFESPAN_FUNC`](./lifespan#var-lifespan-func): 生命周期函数 """ return self.lifespan.on_after_shutdown(func) - def on_before_restart(self, func: LIFESPAN_FUNC): + def on_before_process_shutdown(self, func: PROCESS_LIFESPAN_FUNC): """ - 注册重启前的函数,为子进程重启时调用 + 注册进程停止前的函数,为子进程停止时调用 Args: - func: - + func ([`PROCESS_LIFESPAN_FUNC`](./lifespan#var-process-lifespan-func)): 生命周期函数 Returns: + [`PROCESS_LIFESPAN_FUNC`](./lifespan#var-process-lifespan-func): 生命周期函数 + """ + return self.lifespan.on_before_process_shutdown(func) + def on_before_process_restart( + self, func: PROCESS_LIFESPAN_FUNC + ) -> PROCESS_LIFESPAN_FUNC: + """ + 注册进程重启前的函数,为子进程重启时调用 + Args: + func ([`PROCESS_LIFESPAN_FUNC`](./lifespan#var-process-lifespan-func)): 生命周期函数 + Returns: + [`PROCESS_LIFESPAN_FUNC`](./lifespan#var-process-lifespan-func): 生命周期函数 """ - return self.lifespan.on_before_restart(func) + return self.lifespan.on_before_process_restart(func) def on_after_restart(self, func: LIFESPAN_FUNC): """ 注册重启后的函数:未实现 Args: - func: - + func ([`LIFESPAN_FUNC`](./lifespan#var-lifespan-func)): 生命周期函数 Returns: - + [`LIFESPAN_FUNC`](./lifespan#var-lifespan-func): 生命周期函数 """ return self.lifespan.on_after_restart(func) - def on_after_nonebot_init(self, func: LIFESPAN_FUNC): - """ - 注册nonebot初始化后的函数 - Args: - func: - Returns: - - """ - return self.lifespan.on_after_nonebot_init(func) +_BOT_INSTANCE: LiteyukiBot -_BOT_INSTANCE: Optional[LiteyukiBot] = None - - -def get_bot() -> Optional[LiteyukiBot]: +def get_bot() -> LiteyukiBot: """ 获取轻雪实例 Returns: - LiteyukiBot: 当前的轻雪实例 + [`LiteyukiBot`](#class-liteyukibot): 轻雪实例 """ + if IS_MAIN_PROCESS: + if _BOT_INSTANCE is None: + raise RuntimeError("尹灵温 实例未初始化") return _BOT_INSTANCE else: - # 从多进程上下文中获取 - pass + raise RuntimeError("无法在子进程中获取机器人实例") + + +def get_config(key: str, default: Any = None) -> Any: + """ + 获取配置 + Args: + key ([`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)): 配置键 + default ([`Any`](https%3A//docs.python.org/3/library/functions.html#any), optional): 默认值. Defaults to None. + Returns: + [`Any`](https%3A//docs.python.org/3/library/functions.html#any): 配置值 + """ + return get_bot().config.get(key, default) + + +def get_config_with_compat( + key: str, compat_keys: tuple[str], default: Any = None +) -> Any: + """ + 获取配置,兼容旧版本 + Args: + key ([`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)): 配置键 + compat_keys ([`tuple`](https%3A//docs.python.org/3/library/stdtypes.html#tuple)[`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)): 兼容键 + default ([`Any`](https%3A//docs.python.org/3/library/functions.html#any), optional): 默认值. Defaults to None. + + Returns: + [`Any`](https%3A//docs.python.org/3/library/functions.html#any): 配置值 + """ + if key in get_bot().config: + return get_bot().config[key] + for compat_key in compat_keys: + if compat_key in get_bot().config: + logger.warning(f'配置键 "{compat_key}" 即将被 "{key}" 取代,请及时更新') + return get_bot().config[compat_key] + return default + + +def print_logo(): + """@litedoc-hide""" + print( + "\033[34m" + + r""" + ▅▅▅▅▅▅▅▅▅▅▅▅▅▅██ ▅▅▅▅▅▅▅▅▅▅▅▅▅▅██ ██ ▅▅▅▅▅▅▅▅▅▅█™ + ▛ ██ ██ ▛ ██ ███ ██ ██ + ██ ██ ███████████████ ██ ████████▅ ██ + ███████████████ ██ ███ ██ ██ + ██ ██ ▅██████████████▛ ██ ████████████ + ██ ██ ███ ███ + ████████████████ ██▅ ███ ██ ▅▅▅▅▅▅▅▅▅▅▅██ + ███ █ ▜███████ ██ ███ ██ ██ ██ ██ + ███ ███ █████▛ ██ ██ ██ ██ ██ + ███ ██ ███ █ ██ ██ ██ ██ ██ + ███ █████ ██████ ███ ██████████████ + 商业标记 版权所有 © 2024 金羿Eilles + 机器软件 版权所有 © 2020-2024 神羽SnowyKami & 金羿Eilles\\ + 会同 LiteyukiStudio & 睿乐组织 + 保留所有权利 +""" + + "\033[0m" + ) diff --git a/liteyuki/bot/lifespan.py b/liteyuki/bot/lifespan.py index 25c0b08..50e5681 100644 --- a/liteyuki/bot/lifespan.py +++ b/liteyuki/bot/lifespan.py @@ -8,14 +8,19 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved @File : lifespan.py @Software: PyCharm """ -from typing import Any, Awaitable, Callable, TypeAlias +import asyncio +from typing import Any, Awaitable, Callable, TypeAlias, Sequence from liteyuki.log import logger -from liteyuki.utils import is_coroutine_callable +from liteyuki.utils import is_coroutine_callable, async_wrapper -SYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Any] -ASYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Awaitable[Any]] -LIFESPAN_FUNC: TypeAlias = SYNC_LIFESPAN_FUNC | ASYNC_LIFESPAN_FUNC +SYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Any] # 同步生命周期函数 +ASYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Awaitable[Any]] # 异步生命周期函数 +LIFESPAN_FUNC: TypeAlias = SYNC_LIFESPAN_FUNC | ASYNC_LIFESPAN_FUNC # 生命周期函数 + +SYNC_PROCESS_LIFESPAN_FUNC: TypeAlias = Callable[[str], Any] # 同步进程生命周期函数 +ASYNC_PROCESS_LIFESPAN_FUNC: TypeAlias = Callable[[str], Awaitable[Any]] # 异步进程生命周期函数 +PROCESS_LIFESPAN_FUNC: TypeAlias = SYNC_PROCESS_LIFESPAN_FUNC | ASYNC_PROCESS_LIFESPAN_FUNC # 进程函数 class Lifespan: @@ -23,41 +28,35 @@ class Lifespan: """ 轻雪生命周期管理,启动、停止、重启 """ - - self.life_flag: int = 0 # 0: 启动前,1: 启动后,2: 停止前,3: 停止后 + self.life_flag: int = 0 self._before_start_funcs: list[LIFESPAN_FUNC] = [] self._after_start_funcs: list[LIFESPAN_FUNC] = [] - self._before_shutdown_funcs: list[LIFESPAN_FUNC] = [] + self._before_process_shutdown_funcs: list[PROCESS_LIFESPAN_FUNC] = [] self._after_shutdown_funcs: list[LIFESPAN_FUNC] = [] - self._before_restart_funcs: list[LIFESPAN_FUNC] = [] + self._before_process_restart_funcs: list[PROCESS_LIFESPAN_FUNC] = [] self._after_restart_funcs: list[LIFESPAN_FUNC] = [] - self._after_nonebot_init_funcs: list[LIFESPAN_FUNC] = [] - @staticmethod - async def _run_funcs(funcs: list[LIFESPAN_FUNC]) -> None: + async def run_funcs(funcs: Sequence[LIFESPAN_FUNC | PROCESS_LIFESPAN_FUNC], *args, **kwargs) -> None: """ - 运行函数 + 并发运行异步函数 Args: - funcs: + funcs ([`Sequence`](https%3A//docs.python.org/3/library/typing.html#typing.Sequence)[[`ASYNC_LIFESPAN_FUNC`](#var-lifespan-func) | [`PROCESS_LIFESPAN_FUNC`](#var-process-lifespan-func)]): 函数列表 Returns: """ - for func in funcs: - if is_coroutine_callable(func): - await func() - else: - func() + tasks = [func(*args, **kwargs) if is_coroutine_callable(func) else async_wrapper(func)(*args, **kwargs) for func in funcs] + await asyncio.gather(*tasks) def on_before_start(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: """ 注册启动时的函数 Args: - func: + func ([`LIFESPAN_FUNC`](#var-lifespan-func)): 生命周期函数 Returns: - LIFESPAN_FUNC: + [`LIFESPAN_FUNC`](#var-lifespan-func): 生命周期函数 """ self._before_start_funcs.append(func) return func @@ -66,124 +65,95 @@ class Lifespan: """ 注册启动时的函数 Args: - func: + func ([`LIFESPAN_FUNC`](#var-lifespan-func)): 生命周期函数 Returns: - LIFESPAN_FUNC: + [`LIFESPAN_FUNC`](#var-lifespan-func): 生命周期函数 """ self._after_start_funcs.append(func) return func - def on_before_shutdown(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + def on_before_process_shutdown(self, func: PROCESS_LIFESPAN_FUNC) -> PROCESS_LIFESPAN_FUNC: """ - 注册停止前的函数 + 注册进程停止前的函数 Args: - func: + func ([`PROCESS_LIFESPAN_FUNC`](#var-process-lifespan-func)): 进程生命周期函数 Returns: - LIFESPAN_FUNC: + [`PROCESS_LIFESPAN_FUNC`](#var-process-lifespan-func): 进程生命周期函数 """ - self._before_shutdown_funcs.append(func) + self._before_process_shutdown_funcs.append(func) return func def on_after_shutdown(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: """ 注册停止后的函数 Args: - func: - + func ([`LIFESPAN_FUNC`](#var-lifespan-func)): 生命周期函数 Returns: - LIFESPAN_FUNC: - + [`LIFESPAN_FUNC`](#var-lifespan-func): 生命周期函数 """ self._after_shutdown_funcs.append(func) return func - def on_before_restart(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + def on_before_process_restart(self, func: PROCESS_LIFESPAN_FUNC) -> PROCESS_LIFESPAN_FUNC: """ - 注册重启时的函数 + 注册进程重启前的函数 Args: - func: + func ([`PROCESS_LIFESPAN_FUNC`](#var-process-lifespan-func)): 进程生命周期函数 Returns: - LIFESPAN_FUNC: + [`PROCESS_LIFESPAN_FUNC`](#var-process-lifespan-func): 进程生命周期函数 """ - self._before_restart_funcs.append(func) + self._before_process_restart_funcs.append(func) return func def on_after_restart(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: """ 注册重启后的函数 Args: - func: + func ([`LIFESPAN_FUNC`](#var-lifespan-func)): 生命周期函数 Returns: - LIFESPAN_FUNC: + [`LIFESPAN_FUNC`](#var-lifespan-func): 生命周期函数 """ self._after_restart_funcs.append(func) return func - def on_after_nonebot_init(self, func): - """ - 注册 NoneBot 初始化后的函数 - Args: - func: - - Returns: - - """ - self._after_nonebot_init_funcs.append(func) - return func - async def before_start(self) -> None: """ - 启动前 - Returns: + 启动前钩子 """ - logger.debug("正在运行 before_start 之函数") - await self._run_funcs(self._before_start_funcs) + logger.debug("运行 before_start 函数") + await self.run_funcs(self._before_start_funcs) async def after_start(self) -> None: """ - 启动后 - Returns: + 启动后钩子 """ - logger.debug("正在运行 after_start 之函数") - await self._run_funcs(self._after_start_funcs) + logger.debug("运行 after_start 函数") + await self.run_funcs(self._after_start_funcs) - async def before_shutdown(self) -> None: + async def before_process_shutdown(self, *args, **kwargs) -> None: """ - 停止前 - Returns: + 停止前钩子 """ - logger.debug("正在运行 before_shutdown 之函数") - await self._run_funcs(self._before_shutdown_funcs) + logger.debug("运行 before_shutdown 函数") + await self.run_funcs(self._before_process_shutdown_funcs, *args, **kwargs) async def after_shutdown(self) -> None: """ - 停止后 - Returns: + 停止后钩子 未实现 """ - logger.debug("正在运行 after_shutdown 之函数") - await self._run_funcs(self._after_shutdown_funcs) + logger.debug("运行 after_shutdown 函数") + await self.run_funcs(self._after_shutdown_funcs) - async def before_restart(self) -> None: + async def before_process_restart(self, *args, **kwargs) -> None: """ - 重启前 - Returns: + 重启前钩子 """ - logger.debug("正在运行 before_restart 之函数") - await self._run_funcs(self._before_restart_funcs) + logger.debug("运行 before_restart 函数") + await self.run_funcs(self._before_process_restart_funcs, *args, **kwargs) async def after_restart(self) -> None: """ - 重启后 - Returns: - + 重启后钩子 未实现 """ - logger.debug("正在运行 after_restart 之函数") - await self._run_funcs(self._after_restart_funcs) - - async def after_nonebot_init(self) -> None: - """ - NoneBot 初始化后 - Returns: - """ - logger.debug("正在运行 after_nonebot_init 之函数") - await self._run_funcs(self._after_nonebot_init_funcs) + logger.debug("运行 after_restart 函数") + await self.run_funcs(self._after_restart_funcs) diff --git a/liteyuki/comm/__init__.py b/liteyuki/comm/__init__.py index ac4c6a7..e4bab9a 100644 --- a/liteyuki/comm/__init__.py +++ b/liteyuki/comm/__init__.py @@ -1,30 +1,38 @@ # -*- coding: utf-8 -*- """ -Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved - -@Time : 2024/7/26 下午10:36 -@Author : snowykami -@Email : snowykami@outlook.com -@File : __init__.py -@Software: PyCharm 该模块用于轻雪主进程和Nonebot子进程之间的通信 +依赖关系 +event -> _ +storage -> channel_ +rpc -> channel_, storage """ from liteyuki.comm.channel import ( Channel, - chan, get_channel, set_channel, set_channels, - get_channels + get_channels, + active_channel, + passive_channel ) from liteyuki.comm.event import Event __all__ = [ "Channel", - "chan", "Event", "get_channel", "set_channel", "set_channels", - "get_channels" + "get_channels", + "active_channel", + "passive_channel" ] + +from liteyuki.utils import IS_MAIN_PROCESS + +# 第一次引用必定为赋值 +_ref_count = 0 +if not IS_MAIN_PROCESS: + if (active_channel is None or passive_channel is None) and _ref_count > 0: + raise RuntimeError("无法在子进程中初始化 Channel") + _ref_count += 1 diff --git a/liteyuki/comm/channel.py b/liteyuki/comm/channel.py index ec59edb..677f7f6 100644 --- a/liteyuki/comm/channel.py +++ b/liteyuki/comm/channel.py @@ -1,219 +1,305 @@ # -*- coding: utf-8 -*- """ -Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved - -@Time : 2024/7/26 下午11:21 -@Author : snowykami -@Email : snowykami@outlook.com -@File : channel.py -@Software: PyCharm - 本模块定义了一个通用的通道类,用于进程间通信 """ -import functools -import multiprocessing -import threading +import asyncio from multiprocessing import Pipe -from typing import Any, Optional, Callable, Awaitable, List, TypeAlias -from uuid import uuid4 +from typing import Any, Callable, Coroutine, Generic, Optional, TypeAlias, TypeVar, get_args -from liteyuki.utils import is_coroutine_callable, run_coroutine +from liteyuki.log import logger +from liteyuki.utils import IS_MAIN_PROCESS, is_coroutine_callable -SYNC_ON_RECEIVE_FUNC: TypeAlias = Callable[[Any], Any] -ASYNC_ON_RECEIVE_FUNC: TypeAlias = Callable[[Any], Awaitable[Any]] -ON_RECEIVE_FUNC: TypeAlias = SYNC_ON_RECEIVE_FUNC | ASYNC_ON_RECEIVE_FUNC +T = TypeVar("T") -SYNC_FILTER_FUNC: TypeAlias = Callable[[Any], bool] -ASYNC_FILTER_FUNC: TypeAlias = Callable[[Any], Awaitable[bool]] -FILTER_FUNC: TypeAlias = SYNC_FILTER_FUNC | ASYNC_FILTER_FUNC +SYNC_ON_RECEIVE_FUNC: TypeAlias = Callable[[T], Any] # 同步接收函数 +ASYNC_ON_RECEIVE_FUNC: TypeAlias = Callable[[T], Coroutine[Any, Any, Any]] # 异步接收函数 +ON_RECEIVE_FUNC: TypeAlias = SYNC_ON_RECEIVE_FUNC | ASYNC_ON_RECEIVE_FUNC # 接收函数 -IS_MAIN_PROCESS = multiprocessing.current_process().name == "MainProcess" +SYNC_FILTER_FUNC: TypeAlias = Callable[[T], bool] # 同步过滤函数 +ASYNC_FILTER_FUNC: TypeAlias = Callable[[T], Coroutine[Any, Any, bool]] # 异步过滤函数 +FILTER_FUNC: TypeAlias = SYNC_FILTER_FUNC | ASYNC_FILTER_FUNC # 过滤函数 +_func_id: int = 0 _channel: dict[str, "Channel"] = {} -_callback_funcs: dict[str, ON_RECEIVE_FUNC] = {} +_callback_funcs: dict[int, ON_RECEIVE_FUNC] = {} -class Channel: +class Channel(Generic[T]): """ - 通道类,用于进程间通信,进程内不可用,仅限主进程和子进程之间通信 + 通道类,可以在进程间和进程内通信,双向但同时只能有一个发送者和一个接收者 有两种接收工作方式,但是只能选择一种,主动接收和被动接收,主动接收使用 `receive` 方法,被动接收使用 `on_receive` 装饰器 """ - def __init__(self, _id: str): - self.main_send_conn, self.sub_receive_conn = Pipe() - self.sub_send_conn, self.main_receive_conn = Pipe() - self._closed = False - self._on_main_receive_funcs: list[str] = [] - self._on_sub_receive_funcs: list[str] = [] - self.name: str = _id + def __init__(self, name: str, type_check: Optional[bool] = None): + """ + 初始化通道 + Args: + name: 通道ID + type_check: 是否开启类型检查, 若为空,则传入泛型默认开启,否则默认关闭 + """ - self.is_main_receive_loop_running = False - self.is_sub_receive_loop_running = False + self.conn_send, self.conn_recv = Pipe() + self._conn_send_inner, self._conn_recv_inner = Pipe() # 内部通道,用于子进程通信 + self._closed = False + self._on_main_receive_func_ids: list[int] = [] + self._on_sub_receive_func_ids: list[int] = [] + self.name: str = name + + self.is_receive_loop_running = False + + if type_check is None: + # 若传入泛型则默认开启类型检查 + type_check = self._get_generic_type() is not None + + elif type_check: + if self._get_generic_type() is None: + raise TypeError("Type hint 是强制类型检查之所必须") + self.type_check = type_check + if name in _channel: + raise ValueError(f"Channel {name} 已存在") + + if IS_MAIN_PROCESS: + if name in _channel: + raise ValueError(f"Channel {name} 已存在") + _channel[name] = self + logger.debug(f"Channel {name} 已在主进程中初始化") + else: + logger.debug(f"Channel {name} 已初始化于子进程中,之后应于主进程中手动设置为妙") + + def _get_generic_type(self) -> Optional[type]: + """ + 获取通道传递泛型类型 + Returns: + Optional[type]: 泛型类型 + """ + if hasattr(self, '__orig_class__'): + return get_args(self.__orig_class__)[0] + return None + + def _validate_structure(self, data: Any, structure: type) -> bool: + """ + 验证数据结构 + Args: + data: 数据 + structure: 结构 + Returns: + bool: 是否通过验证 + """ + if isinstance(structure, type): + return isinstance(data, structure) + elif isinstance(structure, tuple): + if not isinstance(data, tuple) or len(data) != len(structure): + return False + return all(self._validate_structure(d, s) for d, s in zip(data, structure)) + elif isinstance(structure, list): + if not isinstance(data, list): + return False + return all(self._validate_structure(d, structure[0]) for d in data) + elif isinstance(structure, dict): + if not isinstance(data, dict): + return False + return all(k in data and self._validate_structure(data[k], structure[k]) for k in structure) + return False def __str__(self): return f"Channel({self.name})" - def send(self, data: Any): + def send(self, data: T): """ - 发送数据 + 发送数据,发送函数为同步函数,没有异步的必要 Args: - data: 数据 + data (T): 数据 """ - if self._closed: - raise RuntimeError("无法发送至已关闭的通道中") - if IS_MAIN_PROCESS: - print("主进程发送数据:", data) - self.main_send_conn.send(data) - else: - print("子进程发送数据:", data) - self.sub_send_conn.send(data) + if self.type_check: + _type = self._get_generic_type() + if _type is not None and not self._validate_structure(data, _type): + raise TypeError(f"该数据必须为 {_type} 实例,而非 {type(data)}") - def receive(self) -> Any: + if self._closed: + raise RuntimeError("数据无法向已关闭的 Channel 中发送") + self.conn_send.send(data) + + def receive(self) -> T: """ - 接收数据 - Args: + 同步接收数据,会阻塞线程 + Returns: + T: 数据 """ if self._closed: - raise RuntimeError("无法从已关闭的通道中接收") + raise RuntimeError("无法在已关闭的 Channel 中接取数据") while True: - # 判断receiver是否为None或者receiver是否等于接收者,是则接收数据,否则不动数据 - if IS_MAIN_PROCESS: - data = self.main_receive_conn.recv() - print("主进程接收数据:", data) - else: - data = self.sub_receive_conn.recv() - print("子进程接收数据:", data) - + data = self.conn_recv.recv() return data - def close(self): + async def async_receive(self) -> T: """ - 关闭通道 + 异步接收数据,会挂起等待 + Returns: + T: 数据 """ - self._closed = True - self.sub_receive_conn.close() - self.main_send_conn.close() - self.sub_send_conn.close() - self.main_receive_conn.close() + loop = asyncio.get_running_loop() + data = await loop.run_in_executor(None, self.receive) + return data - def on_receive(self, filter_func: Optional[FILTER_FUNC] = None) -> Callable[[ON_RECEIVE_FUNC], ON_RECEIVE_FUNC]: + def on_receive(self, filter_func: Optional[FILTER_FUNC] = None) -> Callable[[Callable[[T], Any]], Callable[[T], Any]]: """ 接收数据并执行函数 Args: - filter_func: 过滤函数,为None则不过滤 + filter_func ([`Optional`](https%3A//docs.python.org/3/library/typing.html#typing.Optional)[[`FILTER_FUNC`](#var-FILTER_FUNC)], optional): 过滤函数. Defaults to None. Returns: - 装饰器,装饰一个函数在接收到数据后执行 + Callable[[Callable[[T], Any]], Callable[[T], Any]]: 装饰器 """ - if (not self.is_sub_receive_loop_running) and not IS_MAIN_PROCESS: - threading.Thread(target=self._start_sub_receive_loop).start() + if not IS_MAIN_PROCESS: + raise RuntimeError("on_receive 仅可用于主进程内") - if (not self.is_main_receive_loop_running) and IS_MAIN_PROCESS: - threading.Thread(target=self._start_main_receive_loop).start() + def decorator(func: Callable[[T], Any]) -> Callable[[T], Any]: + global _func_id - def decorator(func: ON_RECEIVE_FUNC) -> ON_RECEIVE_FUNC: - async def wrapper(data: Any) -> Any: + async def wrapper(data: T) -> Any: if filter_func is not None: if is_coroutine_callable(filter_func): - if not await filter_func(data): + if not (await filter_func(data)): # type: ignore return else: if not filter_func(data): return - return await func(data) - function_id = str(uuid4()) - _callback_funcs[function_id] = wrapper + if is_coroutine_callable(func): + return await func(data) + else: + return func(data) + + _callback_funcs[_func_id] = wrapper if IS_MAIN_PROCESS: - self._on_main_receive_funcs.append(function_id) + self._on_main_receive_func_ids.append(_func_id) else: - self._on_sub_receive_funcs.append(function_id) + self._on_sub_receive_func_ids.append(_func_id) + _func_id += 1 return func return decorator - def _run_on_main_receive_funcs(self, data: Any): + async def _run_on_receive_funcs(self, data: Any): """ 运行接收函数 Args: data: 数据 """ - for func_id in self._on_main_receive_funcs: - func = _callback_funcs[func_id] - run_coroutine(func(data)) - - def _run_on_sub_receive_funcs(self, data: Any): - """ - 运行接收函数 - Args: - data: 数据 - """ - for func_id in self._on_sub_receive_funcs: - func = _callback_funcs[func_id] - run_coroutine(func(data)) - - def _start_main_receive_loop(self): - """ - 开始接收数据 - """ - self.is_main_receive_loop_running = True - while not self._closed: - data = self.main_receive_conn.recv() - self._run_on_main_receive_funcs(data) - - def _start_sub_receive_loop(self): - """ - 开始接收数据 - """ - self.is_sub_receive_loop_running = True - while not self._closed: - data = self.sub_receive_conn.recv() - self._run_on_sub_receive_funcs(data) - - def __iter__(self): - return self - - def __next__(self) -> Any: - return self.receive() + if IS_MAIN_PROCESS: + [asyncio.create_task(_callback_funcs[func_id](data)) for func_id in self._on_main_receive_func_ids] + else: + [asyncio.create_task(_callback_funcs[func_id](data)) for func_id in self._on_sub_receive_func_ids] -"""默认通道实例,可直接从模块导入使用""" -chan = Channel("default") +"""子进程可用的主动和被动通道""" +active_channel: Channel = Channel(name="active_channel") # 主动通道 +passive_channel: Channel = Channel(name="passive_channel") # 被动通道 +publish_channel: Channel[tuple[str, dict[str, Any]]] = Channel(name="publish_channel") # 发布通道 +"""通道传递通道,主进程创建单例,子进程初始化时实例化""" +channel_deliver_active_channel: Channel[Channel[Any]] # 主动通道传递通道 +channel_deliver_passive_channel: Channel[tuple[str, dict[str, Any]]] # 被动通道传递通道 + +if IS_MAIN_PROCESS: + channel_deliver_active_channel = Channel(name="channel_deliver_active_channel") # 主动通道传递通道 + channel_deliver_passive_channel = Channel(name="channel_deliver_passive_channel") # 被动通道传递通道 -def set_channel(name: str, channel: Channel): + @channel_deliver_passive_channel.on_receive(filter_func=lambda data: data[0] == "set_channel") + def on_set_channel(data: tuple[str, dict[str, Any]]): + name, channel = data[1]["name"], data[1]["channel_"] + set_channel(name, channel) + + + @channel_deliver_passive_channel.on_receive(filter_func=lambda data: data[0] == "get_channel") + def on_get_channel(data: tuple[str, dict[str, Any]]): + name, recv_chan = data[1]["name"], data[1]["recv_chan"] + recv_chan.send(get_channel(name)) + + + @channel_deliver_passive_channel.on_receive(filter_func=lambda data: data[0] == "get_channels") + def on_get_channels(data: tuple[str, dict[str, Any]]): + recv_chan = data[1]["recv_chan"] + recv_chan.send(get_channels()) + + +def set_channel(name: str, channel: "Channel"): """ 设置通道实例 Args: - name: 通道名称 - channel: 通道实例 + name ([`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)): 通道名称 + channel ([`Channel`](#class-channel-generic-t)): 通道实例 """ - _channel[name] = channel + if not isinstance(channel, Channel): + raise TypeError(f"channel_ 必须为 Channel 实例,而非 {type(channel)}") + + if IS_MAIN_PROCESS: + if name in _channel: + raise ValueError(f"Channel {name} 已存在") + _channel[name] = channel + else: + # 请求主进程设置通道 + channel_deliver_passive_channel.send( + ( + "set_channel", { + "name" : name, + "channel_": channel, + } + ) + ) -def set_channels(channels: dict[str, Channel]): +def set_channels(channels: dict[str, "Channel"]): """ 设置通道实例 Args: - channels: 通道名称 + channels ([`dict`](https%3A//docs.python.org/3/library/stdtypes.html#dict)[[`str`](https%3A//docs.python.org/3/library/stdtypes.html#str), [`Channel`](#class-channel-generic-t)]): 通道实例 """ for name, channel in channels.items(): - _channel[name] = channel + set_channel(name, channel) -def get_channel(name: str) -> Optional[Channel]: +def get_channel(name: str) -> "Channel": """ 获取通道实例 Args: - name: 通道名称 + name ([`str`](https%3A//docs.python.org/3/library/stdtypes.html#str)): 通道名称 Returns: + [`Channel`](#class-channel-generic-t): 通道实例 """ - return _channel.get(name, None) + if IS_MAIN_PROCESS: + return _channel[name] + + else: + recv_chan = Channel[Channel[Any]]("recv_chan") + channel_deliver_passive_channel.send( + ( + "get_channel", + { + "name" : name, + "recv_chan": recv_chan + } + ) + ) + return recv_chan.receive() -def get_channels() -> dict[str, Channel]: +def get_channels() -> dict[str, "Channel"]: """ - 获取通道实例 + 获取通道实例们 Returns: + [`dict`](https%3A//docs.python.org/3/library/stdtypes.html#dict)[[`str`](https%3A//docs.python.org/3/library/stdtypes.html#str), [`Channel`](#class-channel-generic-t)]: 通道实例 """ - return _channel + if IS_MAIN_PROCESS: + return _channel + else: + recv_chan = Channel[dict[str, Channel[Any]]]("recv_chan") + channel_deliver_passive_channel.send( + ( + "get_channels", + { + "recv_chan": recv_chan + } + ) + ) + return recv_chan.receive() diff --git a/liteyuki/comm/event.py b/liteyuki/comm/event.py index c3bddbf..3808b52 100644 --- a/liteyuki/comm/event.py +++ b/liteyuki/comm/event.py @@ -1,12 +1,6 @@ # -*- coding: utf-8 -*- """ -Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved - -@Time : 2024/7/26 下午10:47 -@Author : snowykami -@Email : snowykami@outlook.com -@File : event.py -@Software: PyCharm +本模块用于轻雪主进程和子进程之间的通信的事件类 """ from typing import Any diff --git a/liteyuki/comm/rpc.py b/liteyuki/comm/rpc.py new file mode 100644 index 0000000..6b46f08 --- /dev/null +++ b/liteyuki/comm/rpc.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +""" +本模块用于实现RPC(基于IPC)通信 +""" + +from typing import TypeAlias, Callable, Any + +from liteyuki.comm.channel import Channel + +ON_CALLING_FUNC: TypeAlias = Callable[[tuple, dict], Any] + + +class RPC: + """ + RPC类 + """ + + def __init__(self, on_calling: ON_CALLING_FUNC) -> None: + self.on_calling = on_calling + + def call(self, args: tuple, kwargs: dict) -> Any: + """ + 调用 + """ + # 获取self.calling函数名 + return self.on_calling(args, kwargs) diff --git a/liteyuki/comm/socks_channel.py b/liteyuki/comm/socks_channel.py new file mode 100644 index 0000000..46b8a09 --- /dev/null +++ b/liteyuki/comm/socks_channel.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" +基于socket的通道 +""" + + +class SocksChannel: + """ + 通道类,可以在进程间和进程内通信,双向但同时只能有一个发送者和一个接收者 + 有两种接收工作方式,但是只能选择一种,主动接收和被动接收,主动接收使用 `receive` 方法,被动接收使用 `on_receive` 装饰器 + """ + + def __init__(self, name: str): + """ + 初始化通道 + Args: + name: 通道ID + """ + + self._name = name + self._conn_send = None + self._conn_recv = None + self._closed = False + + def send(self, data): + """ + 发送数据 + Args: + data: 数据 + """ + + pass + + def receive(self): + """ + 接收数据 + Returns: + data: 数据 + """ + + pass + + def close(self): + """ + 关闭通道 + """ + + pass diff --git a/liteyuki/comm/storage.py b/liteyuki/comm/storage.py new file mode 100644 index 0000000..e717b8e --- /dev/null +++ b/liteyuki/comm/storage.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +""" +共享内存模块。类似于redis,但是更加轻量级并且线程安全 +""" +import asyncio +import threading +from typing import Any, Callable, Optional + +from liteyuki.comm import channel +from liteyuki.comm.channel import ASYNC_ON_RECEIVE_FUNC, Channel, ON_RECEIVE_FUNC +from liteyuki.utils import ( + IS_MAIN_PROCESS, + is_coroutine_callable, + run_coroutine_in_thread, +) + +if IS_MAIN_PROCESS: + _locks = {} + +_on_main_subscriber_receive_funcs: dict[str, list[ASYNC_ON_RECEIVE_FUNC]] = {} # type: ignore +"""主进程订阅者接收函数""" +_on_sub_subscriber_receive_funcs: dict[str, list[ASYNC_ON_RECEIVE_FUNC]] = {} # type: ignore +"""子进程订阅者接收函数""" + + +def _get_lock(key) -> threading.Lock: + """ + 获取锁 + """ + if IS_MAIN_PROCESS: + if key not in _locks: + _locks[key] = threading.Lock() + return _locks[key] + else: + raise RuntimeError("无法在子进程中获取线程锁") + + +class KeyValueStore: + def __init__(self): + self._store = {} + self.active_chan = Channel[tuple[str, Optional[dict[str, Any]]]]( + name="shared_memory-active" + ) + self.passive_chan = Channel[tuple[str, Optional[dict[str, Any]]]]( + name="shared_memory-passive" + ) + + self.publish_channel = Channel[tuple[str, Any]](name="shared_memory-publish") + + self.is_main_receive_loop_running = False + self.is_sub_receive_loop_running = False + + def set(self, key: str, value: Any) -> None: + """ + 设置键值对 + Args: + key: 键 + value: 值 + + """ + if IS_MAIN_PROCESS: + lock = _get_lock(key) + with lock: + self._store[key] = value + else: + # 向主进程发送请求拿取 + self.passive_chan.send(("set", {"key": key, "value": value})) + + def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + """ + 获取键值对 + Args: + key: 键 + default: 默认值 + + Returns: + Any: 值 + """ + if IS_MAIN_PROCESS: + lock = _get_lock(key) + with lock: + return self._store.get(key, default) + else: + recv_chan = Channel[Optional[Any]]("recv_chan") + self.passive_chan.send( + ("get", {"key": key, "default": default, "recv_chan": recv_chan}) + ) + return recv_chan.receive() + + def delete(self, key: str, ignore_key_error: bool = True) -> None: + """ + 删除键值对 + Args: + key: 键 + ignore_key_error: 是否忽略键不存在的错误 + + Returns: + """ + if IS_MAIN_PROCESS: + lock = _get_lock(key) + with lock: + if key in self._store: + try: + del self._store[key] + del _locks[key] + except KeyError as e: + if not ignore_key_error: + raise e + else: + # 向主进程发送请求删除 + self.passive_chan.send(("delete", {"key": key})) + + def get_all(self) -> dict[str, Any]: + """ + 获取所有键值对 + Returns: + dict[str, Any]: 键值对 + """ + if IS_MAIN_PROCESS: + return self._store + else: + recv_chan = Channel[dict[str, Any]]("recv_chan") + self.passive_chan.send(("get_all", {"recv_chan": recv_chan})) + return recv_chan.receive() + + def publish(self, channel_: str, data: Any) -> None: + """ + 发布消息 + Args: + channel_: 频道 + data: 数据 + + Returns: + """ + self.active_chan.send(("publish", {"channel": channel_, "data": data})) + + def on_subscriber_receive( + self, channel_: str + ) -> Callable[[ON_RECEIVE_FUNC], ON_RECEIVE_FUNC]: + """ + 订阅者接收消息时的回调 + Args: + channel_: 频道 + + Returns: + 装饰器 + """ + if not IS_MAIN_PROCESS: + raise RuntimeError("无法订阅一子线程消息") + + def decorator(func: ON_RECEIVE_FUNC) -> ON_RECEIVE_FUNC: + async def wrapper(data: Any): + if is_coroutine_callable(func): + await func(data) + else: + func(data) + + if IS_MAIN_PROCESS: + if channel_ not in _on_main_subscriber_receive_funcs: + _on_main_subscriber_receive_funcs[channel_] = [] + _on_main_subscriber_receive_funcs[channel_].append(wrapper) + else: + if channel_ not in _on_sub_subscriber_receive_funcs: + _on_sub_subscriber_receive_funcs[channel_] = [] + _on_sub_subscriber_receive_funcs[channel_].append(wrapper) + return wrapper + + return decorator + + @staticmethod + async def run_subscriber_receive_funcs(channel_: str, data: Any): + """ + 运行订阅者接收函数 + Args: + channel_: 频道 + data: 数据 + """ + [ + asyncio.create_task(func(data)) + for func in _on_main_subscriber_receive_funcs[channel_] + ] + + async def start_receive_loop(self): + """ + 启动发布订阅接收器循环,在主进程中运行,若有子进程订阅则推送给子进程 + """ + + if not IS_MAIN_PROCESS: + raise RuntimeError("无法在子进程中启用订阅接收器循环") + while True: + data = await self.active_chan.async_receive() + if data[0] == "publish": + # 运行主进程订阅函数 + await self.run_subscriber_receive_funcs( + data[1]["channel"], data[1]["data"] + ) + # 推送给子进程 + self.publish_channel.send(data) + + +class GlobalKeyValueStore: + _instance = None + _lock = threading.Lock() + + @classmethod + def get_instance(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = KeyValueStore() + return cls._instance + + +shared_memory: KeyValueStore = GlobalKeyValueStore.get_instance() # 共享内存对象 + +# 全局单例访问点 +if IS_MAIN_PROCESS: + + @shared_memory.passive_chan.on_receive(lambda d: d[0] == "get") + def on_get(data: tuple[str, dict[str, Any]]): + key = data[1]["key"] + default = data[1]["default"] + recv_chan = data[1]["recv_chan"] + recv_chan.send(shared_memory.get(key, default)) + + @shared_memory.passive_chan.on_receive(lambda d: d[0] == "set") + def on_set(data: tuple[str, dict[str, Any]]): + key = data[1]["key"] + value = data[1]["value"] + shared_memory.set(key, value) + + @shared_memory.passive_chan.on_receive(lambda d: d[0] == "delete") + def on_delete(data: tuple[str, dict[str, Any]]): + key = data[1]["key"] + shared_memory.delete(key) + + @shared_memory.passive_chan.on_receive(lambda d: d[0] == "get_all") + def on_get_all(data: tuple[str, dict[str, Any]]): + recv_chan = data[1]["recv_chan"] + recv_chan.send(shared_memory.get_all()) + + +_ref_count = 0 # import 引用计数, 防止获取空指针 +if not IS_MAIN_PROCESS: + if (shared_memory is None) and _ref_count > 1: + raise RuntimeError("共享内存未初始化") + _ref_count += 1 diff --git a/liteyuki/config.py b/liteyuki/config.py index 73c6170..c2601a7 100644 --- a/liteyuki/config.py +++ b/liteyuki/config.py @@ -1,49 +1,132 @@ +""" +该模块用于常用配置文件的加载 +多配置文件编写原则: +1. 尽量不要冲突: 一个键不要多次出现 +2. 分工明确: 每个配置文件给一个或一类服务提供配置 +3. 扁平化编写: 配置文件尽量扁平化,不要出现过多的嵌套 +4. 注意冲突时的优先级: 项目目录下的配置文件优先级高于config目录下的配置文件 +5. 请不要将需要动态加载的内容写入配置文件,你应该使用其他储存方式 +""" + import os -from typing import List - -import nonebot +import json +import copy +import toml import yaml -from pydantic import BaseModel + +from typing import Any + +from liteyuki.log import logger + +_SUPPORTED_CONFIG_FORMATS = (".yaml", ".yml", ".json", ".toml") -config = {} # 主进程全局配置,确保加载后读取 +def flat_config(config: dict[str, Any]) -> dict[str, Any]: + """ + 扁平化配置文件 + + {a:{b:{c:1}}} -> {"a.b.c": 1} + Args: + config: 配置项目 + + Returns: + 扁平化后的配置文件,但也包含原有的键值对 + """ + new_config = copy.deepcopy(config) + for key, value in config.items(): + if isinstance(value, dict): + for k, v in flat_config(value).items(): + new_config[f"{key}.{k}"] = v + return new_config -class SatoriNodeConfig(BaseModel): - host: str = "" - port: str = "5500" - path: str = "" - token: str = "" +def load_from_yaml(file_: str) -> dict[str, Any]: + """ + Load config from yaml file + + """ + logger.debug("正在从 {} 中加载 YAML 配置".format(file_)) + config = yaml.safe_load(open(file_, "r", encoding="utf-8")) + return flat_config(config if config is not None else {}) -class SatoriConfig(BaseModel): - comment: str = "此皆正处于开发之中,切勿在生产环境中启用。" - enable: bool = False - hosts: List[SatoriNodeConfig] = [SatoriNodeConfig()] +def load_from_json(file_: str) -> dict[str, Any]: + """ + Load config from json file + """ + logger.debug("正在从 {} 中加载 JSON 配置".format(file_)) + config = json.load(open(file_, "r", encoding="utf-8")) + return flat_config(config if config is not None else {}) -class BasicConfig(BaseModel): - host: str = "127.0.0.1" - port: int = 20247 - superusers: list[str] = [] - command_start: list[str] = ["/", ""] - nickname: list[str] = [f"灵温"] - satori: SatoriConfig = SatoriConfig() - data_path: str = "data/liteyuki" +def load_from_toml(file_: str) -> dict[str, Any]: + """ + Load config from toml file + """ + logger.debug("正在从 {} 中加载 TOML 配置".format(file_)) + config = toml.load(open(file_, "r", encoding="utf-8")) + return flat_config(config if config is not None else {}) -def load_from_yaml(file: str) -> dict: - global config - nonebot.logger.debug("Loading config from %s" % file) - if not os.path.exists(file): - nonebot.logger.warning(f"未找到配置文件 {file} ,已创建默认配置,请修改后重启。") - with open(file, "w", encoding="utf-8") as f: - yaml.dump(BasicConfig().dict(), f, default_flow_style=False) +def load_from_files(*files: str, no_warning: bool = False) -> dict[str, Any]: + """ + 从指定文件加载配置项,会自动识别文件格式 + 默认执行扁平化选项 + """ + config = {} + for file in files: + if os.path.exists(file): + if file.endswith((".yaml", "yml")): + config.update(load_from_yaml(file)) + elif file.endswith(".json"): + config.update(load_from_json(file)) + elif file.endswith(".toml"): + config.update(load_from_toml(file)) + else: + if not no_warning: + logger.warning(f"不支持配置文件 {file} 的类型") + else: + if not no_warning: + logger.warning(f"配置文件 {file} 未寻得") + return config - with open(file, "r", encoding="utf-8") as f: - conf = yaml.load(f, Loader=yaml.FullLoader) - config = conf - if conf is None: - nonebot.logger.warning(f"配置文件 {file} 为空,已创建默认配置,请修改后重启。") - conf = BasicConfig().dict() - return conf + +def load_configs_from_dirs( + *directories: str, no_waring: bool = False +) -> dict[str, Any]: + """ + 从目录下加载配置文件,不递归 + 按照读取文件的优先级反向覆盖 + 默认执行扁平化选项 + """ + config = {} + for directory in directories: + if not os.path.exists(directory): + if not no_waring: + logger.warning(f"目录 {directory} 未寻得") + continue + for file in os.listdir(directory): + if file.endswith(_SUPPORTED_CONFIG_FORMATS): + config.update( + load_from_files(os.path.join(directory, file), no_warning=no_waring) + ) + return config + + +def load_config_in_default(no_waring: bool = False) -> dict[str, Any]: + """ + 从一个标准的轻雪项目加载配置文件 + 项目目录下的config.*和config目录下的所有配置文件 + 项目目录下的配置文件优先 + """ + config = load_configs_from_dirs("config", no_waring=no_waring) + config.update( + load_from_files( + "config.yaml", + "config.toml", + "config.json", + "config.yml", + no_warning=no_waring, + ) + ) + return config diff --git a/liteyuki/core/__init__.py b/liteyuki/core/__init__.py index f95f814..625d840 100644 --- a/liteyuki/core/__init__.py +++ b/liteyuki/core/__init__.py @@ -1,11 +1,2 @@ -import multiprocessing - -from .spawn_process import * from .manager import * -__all__ = [ - "IS_MAIN_PROCESS" -] - -IS_MAIN_PROCESS = multiprocessing.current_process().name == "MainProcess" - diff --git a/liteyuki/core/manager.py b/liteyuki/core/manager.py index 944a8dd..6a209b1 100644 --- a/liteyuki/core/manager.py +++ b/liteyuki/core/manager.py @@ -9,92 +9,175 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved @Software: PyCharm """ import asyncio +import multiprocessing import threading from multiprocessing import Process -from typing import TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING, TypeAlias -from liteyuki.comm import Channel, get_channel, set_channels from liteyuki.log import logger +from liteyuki.utils import IS_MAIN_PROCESS if TYPE_CHECKING: - from liteyuki.bot import LiteyukiBot + from liteyuki.bot.lifespan import Lifespan + from liteyuki.comm.storage import KeyValueStore +from liteyuki.comm import Channel + +if IS_MAIN_PROCESS: + from liteyuki.comm.channel import get_channel, publish_channel, get_channels + from liteyuki.comm.storage import shared_memory + from liteyuki.comm.channel import ( + channel_deliver_active_channel, + channel_deliver_passive_channel, + ) +else: + from liteyuki.comm import channel + from liteyuki.comm import storage + +TARGET_FUNC: TypeAlias = Callable[..., Any] TIMEOUT = 10 __all__ = ["ProcessManager"] +multiprocessing.set_start_method("spawn", force=True) + + +class ChannelDeliver: + def __init__( + self, + active: Channel[Any], + passive: Channel[Any], + channel_deliver_active: Channel[Channel[Any]], + channel_deliver_passive: Channel[tuple[str, dict]], + publish: Channel[tuple[str, Any]], + ): + self.active = active + self.passive = passive + self.channel_deliver_active = channel_deliver_active + self.channel_deliver_passive = channel_deliver_passive + self.publish = publish + + +# 函数处理一些跨进程通道的 +def _delivery_channel_wrapper( + func: TARGET_FUNC, cd: ChannelDeliver, sm: "KeyValueStore", *args, **kwargs +): + """ + 子进程入口函数 + 处理一些操作 + """ + # 给子进程设置通道 + if IS_MAIN_PROCESS: + raise RuntimeError("函数仅可在子进程中被调用") + + channel.active_channel = cd.active # 子进程主动通道 + channel.passive_channel = cd.passive # 子进程被动通道 + channel.channel_deliver_active_channel = ( + cd.channel_deliver_active + ) # 子进程通道传递主动通道 + channel.channel_deliver_passive_channel = ( + cd.channel_deliver_passive + ) # 子进程通道传递被动通道 + channel.publish_channel = cd.publish # 子进程发布通道 + + # 给子进程创建共享内存实例 + + storage.shared_memory = sm + + func(*args, **kwargs) class ProcessManager: """ - 在主进程中被调用 + 进程管理器 """ - def __init__(self, bot: "LiteyukiBot"): - self.bot = bot - self.targets: dict[str, tuple[callable, tuple, dict]] = {} + def __init__(self, lifespan: "Lifespan"): + self.lifespan = lifespan + self.targets: dict[str, tuple[Callable, tuple, dict]] = {} self.processes: dict[str, Process] = {} - set_channels( - { - "nonebot-active": Channel(_id="nonebot-active"), - "melobot-active": Channel(_id="melobot-active"), - "nonebot-passive": Channel(_id="nonebot-passive"), - "melobot-passive": Channel(_id="melobot-passive"), - } - ) - - def start(self, name: str, delay: int = 0): + def _run_process(self, name: str): """ - 开启后自动监控进程,并添加到进程字典中 + 开启后自动监控进程,并添加到进程字典中,会阻塞,请创建task Args: name: - delay: - Returns: - """ if name not in self.targets: - raise KeyError(f"未有 Process {name} 之存在") + raise KeyError(f"Process {name} 未寻得") - def _start(): - should_exit = False - while not should_exit: - chan_active = get_channel(f"{name}-active") - chan_passive = get_channel(f"{name}-passive") - process = Process( - target=self.targets[name][0], - args=(chan_active, chan_passive, *self.targets[name][1]), - kwargs=self.targets[name][2], - ) - self.processes[name] = process - process.start() - while not should_exit: - # 0退出 1重启 - data = chan_active.receive() - if data == 1: - logger.info(f"重启 {name} 进程") - asyncio.run(self.bot.lifespan.before_shutdown()) - asyncio.run(self.bot.lifespan.before_restart()) - self.terminate(name) - break + chan_active = get_channel(f"{name}-active") - elif data == 0: - logger.info(f"关停 {name} 进程") - asyncio.run(self.bot.lifespan.before_shutdown()) - should_exit = True - self.terminate(name) - else: - logger.warning("数据未知,省略:{}".format(data)) + def _start_process(): + process = Process( + target=self.targets[name][0], + args=self.targets[name][1], + kwargs=self.targets[name][2], + daemon=True, + ) + self.processes[name] = process + process.start() - if delay: - threading.Timer(delay, _start).start() - else: - threading.Thread(target=_start).start() + # 启动进程并监听信号 + _start_process() + while True: + data = chan_active.receive() + if data == 0: + # 停止 + logger.info(f"正在关停 Process {name}") + self.terminate(name) + break + elif data == 1: + # 重启 + logger.info(f"正在重启 Process {name}") + self.terminate(name) + _start_process() + continue + else: + logger.warning("接收到未知信号数据 {} ,已忽略".format(data)) - def add_target(self, name: str, target, *args, **kwargs): - self.targets[name] = (target, args, kwargs) + def start_all(self): + """ + 对外启动方法,启动所有进程,创建asyncio task + """ + # [asyncio.create_task(self._run_process(name)) for name in self.targets] - def join(self): + for name in self.targets: + logger.debug(f"正在启动 Process {name}") + threading.Thread( + target=self._run_process, args=(name,), daemon=True + ).start() + + def add_target(self, name: str, target: TARGET_FUNC, args: tuple = (), kwargs=None): + """ + 添加进程 + Args: + name: 进程名,用于获取和唯一标识 + target: 进程函数 + args: 进程函数参数 + kwargs: 进程函数关键字参数,通常会默认传入chan_active和chan_passive + """ + if kwargs is None: + kwargs = {} + chan_active: Channel = Channel(name=f"{name}-active") + chan_passive: Channel = Channel(name=f"{name}-passive") + + channel_deliver = ChannelDeliver( + active=chan_active, + passive=chan_passive, + channel_deliver_active=channel_deliver_active_channel, + channel_deliver_passive=channel_deliver_passive_channel, + publish=publish_channel, + ) + + self.targets[name] = ( + _delivery_channel_wrapper, + (target, channel_deliver, shared_memory, *args), + kwargs, + ) + # 主进程通道 + + def join_all(self): for name, process in self.targets: process.join() @@ -107,14 +190,29 @@ class ProcessManager: Returns: """ - if name not in self.targets: - raise logger.warning(f"未有 Process {name} 之存在") + if name not in self.processes: + logger.warning(f"Process {name} 未寻得") + return process = self.processes[name] process.terminate() process.join(TIMEOUT) if process.is_alive(): process.kill() + logger.success(f"Process {name} 已迫令终止") def terminate_all(self): for name in self.targets: self.terminate(name) + + def is_process_alive(self, name: str) -> bool: + """ + 检查进程是否存活 + Args: + name: + + Returns: + + """ + if name not in self.targets: + logger.warning(f"Process {name} 未寻得") + return self.processes[name].is_alive() diff --git a/liteyuki/core/spawn_process.py b/liteyuki/core/spawn_process.py deleted file mode 100644 index 040392a..0000000 --- a/liteyuki/core/spawn_process.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import Optional, TYPE_CHECKING - -import nonebot - -from liteyuki.core.nb import adapter_manager, driver_manager -from liteyuki.comm.channel import set_channel - -if TYPE_CHECKING: - from liteyuki.comm.channel import Channel - -timeout_limit: int = 20 - -"""导出对象,用于主进程与nonebot通信""" -_channels = {} - - -def nb_run(chan_active: "Channel", chan_passive: "Channel", *args, **kwargs): - """ - 初始化NoneBot并运行在子进程 - Args: - - chan_active: - chan_passive: - **kwargs: - - Returns: - - """ - set_channel("nonebot-active", chan_active) - set_channel("nonebot-passive", chan_passive) - nonebot.init(**kwargs) - driver_manager.init(config=kwargs) - adapter_manager.init(kwargs) - adapter_manager.register() - nonebot.load_plugin("src.liteyuki_main") - nonebot.run() - - -def mb_run(chan_active: "Channel", chan_passive: "Channel", *args, **kwargs): - """ - 初始化MeloBot并运行在子进程 - Args: - chan_active - chan_passive - *args: - **kwargs: - - Returns: - - """ - set_channel("melobot-active", chan_active) - set_channel("melobot-passive", chan_passive) - - # bot = MeloBot(__name__) - # bot.init(AbstractConnector(cd_time=0)) - # bot.run() diff --git a/liteyuki/dev/__init__.py b/liteyuki/dev/__init__.py new file mode 100644 index 0000000..893c170 --- /dev/null +++ b/liteyuki/dev/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +该模块用于存放一些开发工具 +""" diff --git a/liteyuki/dev/observer.py b/liteyuki/dev/observer.py new file mode 100644 index 0000000..6be869c --- /dev/null +++ b/liteyuki/dev/observer.py @@ -0,0 +1,90 @@ +""" +此模块用于注册观察者函数,使用watchdog监控文件变化并重启bot +启用该模块需要在配置文件中设置`dev_mode`为True +""" +import time +from typing import Callable, TypeAlias + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from liteyuki import get_bot, get_config_with_compat, logger + +liteyuki_bot = get_bot() + +CALLBACK_FUNC: TypeAlias = Callable[[FileSystemEvent], None] # 位置1为FileSystemEvent +FILTER_FUNC: TypeAlias = Callable[[FileSystemEvent], bool] # 位置1为FileSystemEvent +observer = Observer() + + +def debounce(wait): + """ + 防抖函数 + """ + def decorator(func): + def wrapper(*args, **kwargs): + nonlocal last_call_time + current_time = time.time() + if (current_time - last_call_time) > wait: + last_call_time = current_time + return func(*args, **kwargs) + + last_call_time = None + return wrapper + + return decorator + + +if get_config_with_compat("liteyuki.reload", ("dev_mode",), False): + logger.debug("Liteyuki Reload 已启用,正在监视文件更新") + observer.start() + + +class CodeModifiedHandler(FileSystemEventHandler): + """ + Handler for code file changes + """ + + @debounce(1) + def on_modified(self, event): + raise NotImplementedError("on_modified 函数在继承后必须实现") + + def on_created(self, event): + self.on_modified(event) + + def on_deleted(self, event): + self.on_modified(event) + + def on_moved(self, event): + self.on_modified(event) + + def on_any_event(self, event): + self.on_modified(event) + + +def on_file_system_event(directories: tuple[str], recursive: bool = True, event_filter: FILTER_FUNC = None) -> Callable[[CALLBACK_FUNC], CALLBACK_FUNC]: + """ + 注册文件系统变化监听器 + Args: + directories: 监听目录们 + recursive: 是否递归监听子目录 + event_filter: 事件过滤器, 返回True则执行回调函数 + Returns: + 装饰器,装饰一个函数在接收到数据后执行 + """ + + def decorator(func: CALLBACK_FUNC) -> CALLBACK_FUNC: + def wrapper(event: FileSystemEvent): + + if event_filter is not None and not event_filter(event): + return + func(event) + + code_modified_handler = CodeModifiedHandler() + code_modified_handler.on_modified = wrapper + for directory in directories: + observer.schedule(code_modified_handler, directory, recursive=recursive) + + return func + + return decorator diff --git a/liteyuki/dev/plugin.py b/liteyuki/dev/plugin.py new file mode 100644 index 0000000..c541bba --- /dev/null +++ b/liteyuki/dev/plugin.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/18 上午5:04 +@Author : snowykami +@Email : snowykami@outlook.com +@File : plugin.py +@Software: PyCharm +""" +from pathlib import Path + +from liteyuki.bot import LiteyukiBot +from liteyuki.config import load_config_in_default + + +def run_plugins(*module_path: str | Path): + """ + 运行插件,无需手动初始化bot + Args: + module_path: 插件路径,参考`liteyuki.load_plugin`的函数签名 + """ + cfg = load_config_in_default() + plugins = cfg.get("liteyuki.plugins", []) + plugins.extend(module_path) + cfg["liteyuki.plugins"] = plugins + bot = LiteyukiBot(**cfg) + bot.run() diff --git a/liteyuki/log.py b/liteyuki/log.py index a779a14..1e8a92b 100644 --- a/liteyuki/log.py +++ b/liteyuki/log.py @@ -9,22 +9,10 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved @Software: PyCharm """ import sys + import loguru -from typing import TYPE_CHECKING logger = loguru.logger -if TYPE_CHECKING: - # avoid sphinx autodoc resolve annotation failed - # because loguru module do not have `Logger` class actually - from loguru import Record - - -def default_filter(record: "Record"): - """默认的日志过滤器,根据 `config.log_level` 配置改变日志等级。""" - log_level = record["extra"].get("nonebot_log_level", "INFO") - levelno = logger.level(log_level).no if isinstance(log_level, str) else log_level - return record["level"].no >= levelno - # DEBUG日志格式 debug_format: str = ( @@ -50,35 +38,26 @@ def get_format(level: str) -> str: return default_format -logger = loguru.logger.bind() - - def init_log(config: dict): """ 在语言加载完成后执行 Returns: """ - global logger logger.remove() logger.add( sys.stdout, level=0, diagnose=False, - filter=default_filter, format=get_format(config.get("log_level", "INFO")), ) show_icon = config.get("log_icon", True) + logger.level("DEBUG", color="", icon=f"{'🐛' if show_icon else ''}试") + logger.level("INFO", color="", icon=f"{'ℹ️' if show_icon else ''}讯") + logger.level("SUCCESS", color="", icon=f"{'✅' if show_icon else ''}警") + logger.level("WARNING", color="", icon=f"{'⚠️' if show_icon else ''}误") + logger.level("ERROR", color="", icon=f"{'⭕' if show_icon else ''}成") - # debug = lang.get("log.debug", default="==DEBUG") - # info = lang.get("log.info", default="===INFO") - # success = lang.get("log.success", default="SUCCESS") - # warning = lang.get("log.warning", default="WARNING") - # error = lang.get("log.error", default="==ERROR") - # - # logger.level("DEBUG", color="", icon=f"{'🐛' if show_icon else ''}{debug}") - # logger.level("INFO", color="", icon=f"{'ℹ️' if show_icon else ''}{info}") - # logger.level("SUCCESS", color="", icon=f"{'✅' if show_icon else ''}{success}") - # logger.level("WARNING", color="", icon=f"{'⚠️' if show_icon else ''}{warning}") - # logger.level("ERROR", color="", icon=f"{'⭕' if show_icon else ''}{error}") + +init_log(config={}) diff --git a/liteyuki/message/__init__.py b/liteyuki/message/__init__.py new file mode 100644 index 0000000..8c566fd --- /dev/null +++ b/liteyuki/message/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 下午10:44 +@Author : snowykami +@Email : snowykami@outlook.com +@File : __init__.py.py +@Software: PyCharm +""" diff --git a/liteyuki/message/event.py b/liteyuki/message/event.py new file mode 100644 index 0000000..a68a60e --- /dev/null +++ b/liteyuki/message/event.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 下午10:47 +@Author : snowykami +@Email : snowykami@outlook.com +@File : event.py +@Software: PyCharm +""" +from typing import Any, Optional + +from liteyuki import Channel +from liteyuki.comm.storage import shared_memory + + +class MessageEvent: + def __init__( + self, + + bot_id: str, + message: list[dict[str, Any]] | str, + message_type: str, + raw_message: str, + session_id: str, + user_id: str, + session_type: str, + receive_channel: Optional[Channel["MessageEvent"]] = None, + data: Optional[dict[str, Any]] = None, + ): + """ + 轻雪抽象消息事件 + Args: + + bot_id: 机器人ID + message: 消息,消息段数组[{type: str, data: dict[str, Any]}] + raw_message: 原始消息(通常为纯文本的格式) + message_type: 消息类型(private, group, other) + + session_id: 会话ID(私聊通常为用户ID,群聊通常为群ID) + session_type: 会话类型(private, group) + receive_channel: 接收频道(用于回复消息) + + data: 附加数据 + """ + + if data is None: + data = {} + self.message_type = message_type + self.data = data + self.bot_id = bot_id + + self.message = message + self.raw_message = raw_message + + self.session_id = session_id + self.session_type = session_type + self.user_id = user_id + + self.receive_channel = receive_channel + + def __str__(self): + return (f"Event(message_type={self.message_type}, data={self.data}, bot_id={self.bot_id}, " + f"session_id={self.session_id}, session_type={self.session_type})") + + def reply(self, message: str | dict[str, Any]): + """ + 回复消息 + Args: + message: + Returns: + """ + reply_event = MessageEvent( + message_type=self.session_type, + message=message, + raw_message="", + data={ + "message": message + }, + bot_id=self.bot_id, + session_id=self.session_id, + user_id=self.user_id, + session_type=self.session_type, + receive_channel=None + ) + # shared_memory.publish(self.receive_channel, reply_event) + if self.receive_channel: + self.receive_channel.send(reply_event) diff --git a/liteyuki/message/matcher.py b/liteyuki/message/matcher.py new file mode 100644 index 0000000..d2add76 --- /dev/null +++ b/liteyuki/message/matcher.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 下午10:51 +@Author : snowykami +@Email : snowykami@outlook.com +@File : matcher.py +@Software: PyCharm +""" +import traceback +from typing import Any, TypeAlias, Callable, Coroutine + +from liteyuki.message.event import MessageEvent +from liteyuki.message.rule import Rule + +EventHandler: TypeAlias = Callable[[MessageEvent], Coroutine[None, None, Any]] + + +class Matcher: + def __init__(self, rule: Rule, priority: int, block: bool): + """ + 匹配器 + Args: + rule: 规则 + priority: 优先级 >= 0 + block: 是否阻断后续优先级更低的匹配器 + """ + self.rule = rule + self.priority = priority + self.block = block + self.handlers: list[EventHandler] = [] + + def __str__(self): + return f"Matcher(rule={self.rule}, priority={self.priority}, block={self.block})" + + def handle(self) -> Callable[[EventHandler], EventHandler]: + """ + 添加处理函数,装饰器 + Returns: + 装饰器 handler + """ + def decorator(handler: EventHandler) -> EventHandler: + self.handlers.append(handler) + return handler + + return decorator + + async def run(self, event: MessageEvent) -> None: + """ + 运行处理函数 + Args: + event: + Returns: + """ + if not await self.rule(event): + return + + for handler in self.handlers: + try: + await handler(event) + except Exception: + traceback.print_exc() diff --git a/liteyuki/message/on.py b/liteyuki/message/on.py new file mode 100644 index 0000000..2324662 --- /dev/null +++ b/liteyuki/message/on.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 下午10:52 +@Author : snowykami +@Email : snowykami@outlook.com +@File : on.py +@Software: PyCharm +""" + +from queue import Queue + +from liteyuki.comm.storage import shared_memory +from liteyuki.log import logger +from liteyuki.message.event import MessageEvent +from liteyuki.message.matcher import Matcher +from liteyuki.message.rule import Rule, empty_rule + +_matcher_list: list[Matcher] = [] +_queue: Queue = Queue() + + +@shared_memory.on_subscriber_receive("event_to_liteyuki") +async def _(event: MessageEvent): + print("AA") + current_priority = -1 + for i, matcher in enumerate(_matcher_list): + logger.info(f"为 Event {event} 运行 Matcher {matcher}") + await matcher.run(event) + # 同优先级不阻断,不同优先级阻断 + if current_priority != matcher.priority: + current_priority = matcher.priority + if matcher.block: + break + else: + logger.info(f"无 Matcher 适配于 Event {event}") + print("BB") + + +def add_matcher(matcher: Matcher): + for i, m in enumerate(_matcher_list): + if m.priority < matcher.priority: + _matcher_list.insert(i, matcher) + break + else: + _matcher_list.append(matcher) + + +def on_message(rule: Rule = empty_rule, priority: int = 0, block: bool = False) -> Matcher: + matcher = Matcher(rule, priority, block) + # 按照优先级插入 + add_matcher(matcher) + return matcher + + +def on_keywords(keywords: list[str], rule=empty_rule, priority: int = 0, block: bool = False) -> Matcher: + @Rule + async def on_keywords_rule(event: MessageEvent): + return any(keyword in event.raw_message for keyword in keywords) + + return on_message(on_keywords_rule & rule, priority, block) diff --git a/liteyuki/message/rule.py b/liteyuki/message/rule.py new file mode 100644 index 0000000..e2f3291 --- /dev/null +++ b/liteyuki/message/rule.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 下午10:55 +@Author : snowykami +@Email : snowykami@outlook.com +@File : rule.py +@Software: PyCharm +""" +import inspect +from typing import Optional, TypeAlias, Callable, Coroutine + +from liteyuki.message.event import MessageEvent +from liteyuki import get_config + +_superusers: list[str] = get_config("liteyuki.superusers", []) + +RuleHandlerFunc: TypeAlias = Callable[[MessageEvent], Coroutine[None, None, bool]] +"""规则函数签名""" + + +class Rule: + def __init__(self, handler: RuleHandlerFunc): + self.handler = handler + + def __or__(self, other: "Rule") -> "Rule": + async def combined_handler(event: MessageEvent) -> bool: + return await self.handler(event) or await other.handler(event) + + return Rule(combined_handler) + + def __and__(self, other: "Rule") -> "Rule": + async def combined_handler(event: MessageEvent) -> bool: + return await self.handler(event) and await other.handler(event) + + return Rule(combined_handler) + + async def __call__(self, event: MessageEvent) -> bool: + if self.handler is None: + return True + return await self.handler(event) + + +@Rule +async def empty_rule(event: MessageEvent) -> bool: + return True + +@Rule +async def is_su_rule(event: MessageEvent) -> bool: + return str(event.user_id) in _superusers diff --git a/liteyuki/plugins/reloader_monitor.py b/liteyuki/message/session.py similarity index 72% rename from liteyuki/plugins/reloader_monitor.py rename to liteyuki/message/session.py index 4900e21..e125511 100644 --- a/liteyuki/plugins/reloader_monitor.py +++ b/liteyuki/message/session.py @@ -2,9 +2,9 @@ """ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved -@Time : 2024/8/10 下午5:18 +@Time : 2024/8/19 下午10:47 @Author : snowykami @Email : snowykami@outlook.com -@File : reloader_monitor.py +@File : session.py @Software: PyCharm """ \ No newline at end of file diff --git a/liteyuki/mkdoc.py b/liteyuki/mkdoc.py new file mode 100644 index 0000000..7463eca --- /dev/null +++ b/liteyuki/mkdoc.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 上午6:23 +@Author : snowykami +@Email : snowykami@outlook.com +@File : mkdoc.py +@Software: PyCharm +""" + +import ast +import os +import shutil +from typing import Any +from enum import Enum +from pydantic import BaseModel + +NO_TYPE_ANY = "Any" +NO_TYPE_HINT = "NoTypeHint" + + +class DefType(Enum): + FUNCTION = "function" + METHOD = "method" + STATIC_METHOD = "staticmethod" + CLASS_METHOD = "classmethod" + PROPERTY = "property" + + +class FunctionInfo(BaseModel): + name: str + args: list[tuple[str, str]] + return_type: str + docstring: str + source_code: str = "" + + type: DefType + """若为类中def,则有""" + is_async: bool + + +class AttributeInfo(BaseModel): + name: str + type: str + value: Any = None + docstring: str = "" + + +class ClassInfo(BaseModel): + name: str + docstring: str + methods: list[FunctionInfo] + attributes: list[AttributeInfo] + inherit: list[str] + + +class ModuleInfo(BaseModel): + module_path: str + """点分割模块路径 例如 liteyuki.bot""" + + functions: list[FunctionInfo] + classes: list[ClassInfo] + attributes: list[AttributeInfo] + docstring: str + + +def get_relative_path(base_path: str, target_path: str) -> str: + """ + 获取相对路径 + Args: + base_path: 基础路径 + target_path: 目标路径 + """ + return os.path.relpath(target_path, base_path) + + +def write_to_files(file_data: dict[str, str]): + """ + 输出文件 + Args: + file_data: 文件数据 相对路径 + """ + + for rp, data in file_data.items(): + + if not os.path.exists(os.path.dirname(rp)): + os.makedirs(os.path.dirname(rp)) + with open(rp, 'w', encoding='utf-8') as f: + f.write(data) + + +def get_file_list(module_folder: str): + file_list = [] + for root, dirs, files in os.walk(module_folder): + for file in files: + if file.endswith((".py", ".pyi")): + file_list.append(os.path.join(root, file)) + return file_list + + +def get_module_info_normal(file_path: str, ignore_private: bool = True) -> ModuleInfo: + """ + 获取函数和类 + Args: + file_path: Python 文件路径 + ignore_private: 忽略私有函数和类 + Returns: + 模块信息 + """ + + with open(file_path, 'r', encoding='utf-8') as file: + file_content = file.read() + tree = ast.parse(file_content) + + dot_sep_module_path = file_path.replace(os.sep, '.').replace(".py", "").replace(".pyi", "") + module_docstring = ast.get_docstring(tree) + + module_info = ModuleInfo( + module_path=dot_sep_module_path, + functions=[], + classes=[], + attributes=[], + docstring=module_docstring if module_docstring else "" + ) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # 模块函数 且不在类中 若ignore_private=True则忽略私有函数 + if not any(isinstance(parent, ast.ClassDef) for parent in ast.iter_child_nodes(node)) and (not ignore_private or not node.name.startswith('_')): + + # 判断第一个参数是否为self或cls,后期用其他办法优化 + if node.args.args: + first_arg = node.args.args[0] + if first_arg.arg in ("self", "cls"): + continue + + function_docstring = ast.get_docstring(node) + + func_info = FunctionInfo( + name=node.name, + args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in node.args.args], + return_type=ast.unparse(node.returns) if node.returns else "None", + docstring=function_docstring if function_docstring else "", + type=DefType.FUNCTION, + is_async=isinstance(node, ast.AsyncFunctionDef), + source_code=ast.unparse(node) + ) + module_info.functions.append(func_info) + + elif isinstance(node, ast.ClassDef): + # 模块类 + class_docstring = ast.get_docstring(node) + + class_info = ClassInfo( + name=node.name, + docstring=class_docstring if class_docstring else "", + methods=[], + attributes=[], + inherit=[ast.unparse(base) for base in node.bases] + ) + + for class_node in node.body: + # methods [instance, static, class, property],保留__init__方法 + if isinstance(class_node, ast.FunctionDef) and (not ignore_private or not class_node.name.startswith('_') or class_node.name == "__init__"): + method_docstring = ast.get_docstring(class_node) + def_type = DefType.METHOD + if class_node.decorator_list: + if any(isinstance(decorator, ast.Name) and decorator.id == "staticmethod" for decorator in class_node.decorator_list): + def_type = DefType.STATIC_METHOD + elif any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in class_node.decorator_list): + def_type = DefType.CLASS_METHOD + elif any(isinstance(decorator, ast.Name) and decorator.id == "property" for decorator in class_node.decorator_list): + def_type = DefType.PROPERTY + class_info.methods.append(FunctionInfo( + name=class_node.name, + args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in class_node.args.args], + return_type=ast.unparse(class_node.returns) if class_node.returns else "None", + docstring=method_docstring if method_docstring else "", + type=def_type, + is_async=isinstance(class_node, ast.AsyncFunctionDef), + source_code=ast.unparse(class_node) + )) + # attributes + elif isinstance(class_node, ast.Assign): + for target in class_node.targets: + if isinstance(target, ast.Name): + class_info.attributes.append(AttributeInfo( + name=target.id, + type=ast.unparse(class_node.value) + )) + module_info.classes.append(class_info) + + elif isinstance(node, ast.Assign): + # 检查是否在类或函数中 + if not any(isinstance(parent, (ast.ClassDef, ast.FunctionDef)) for parent in ast.iter_child_nodes(node)): + # 模块属性变量 + for target in node.targets: + if isinstance(target, ast.Name) and (not ignore_private or not target.id.startswith('_')): + attr_type = NO_TYPE_HINT + if isinstance(node.value, ast.AnnAssign) and node.value.annotation: + attr_type = ast.unparse(node.value.annotation) + module_info.attributes.append(AttributeInfo( + name=target.id, + type=attr_type, + value=ast.unparse(node.value) if node.value else None + )) + + return module_info + + +def generate_markdown(module_info: ModuleInfo, front_matter=None, lang: str = "zh-CN") -> str: + """ + 生成模块的Markdown + 你可在此自定义生成的Markdown格式 + Args: + module_info: 模块信息 + front_matter: 自定义选项title, index, icon, category + lang: 语言 + Returns: + Markdown 字符串 + """ + + content = "" + + front_matter = "---\n" + "\n".join([f"{k}: {v}" for k, v in front_matter.items()]) + "\n---\n\n" + + content += front_matter + + # 模块函数 + for func in module_info.functions: + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] else arg[0] for arg in func.args] + content += f"### ***{'async ' if func.is_async else ''}def*** `{func.name}({', '.join(args_with_type)}) -> {func.return_type}`\n\n" + + func.docstring = func.docstring.replace("\n", "\n\n") + content += f"{func.docstring}\n\n" + + # 函数源代码可展开区域 + content += f"
\n源代码\n\n```python\n{func.source_code}\n```\n
\n\n" + + # 类 + for cls in module_info.classes: + if cls.inherit: + inherit = f"({', '.join(cls.inherit)})" if cls.inherit else "" + content += f"### ***class*** `{cls.name}{inherit}`\n\n" + else: + content += f"### ***class*** `{cls.name}`\n\n" + + cls.docstring = cls.docstring.replace("\n", "\n\n") + content += f"{cls.docstring}\n\n" + for method in cls.methods: + # 类函数 + + if method.type != DefType.METHOD: + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] else arg[0] for arg in method.args] + content += f"###   ***@{method.type.value}***\n" + else: + # self不加类型提示 + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] and arg[0] != "self" else arg[0] for arg in method.args] + content += f"###   ***{'async ' if method.is_async else ''}def*** `{method.name}({', '.join(args_with_type)}) -> {method.return_type}`\n\n" + + method.docstring = method.docstring.replace("\n", "\n\n") + content += f" {method.docstring}\n\n" + # 函数源代码可展开区域 + + if lang == "zh-CN": + TEXT_SOURCE_CODE = "源代码" + else: + TEXT_SOURCE_CODE = "Source Code" + + content += f"
\n{TEXT_SOURCE_CODE}\n\n```python\n{method.source_code}\n```\n
\n\n" + for attr in cls.attributes: + content += f"###   ***attr*** `{attr.name}: {attr.type}`\n\n" + + # 模块属性 + for attr in module_info.attributes: + if attr.type == NO_TYPE_HINT: + content += f"### ***var*** `{attr.name} = {attr.value}`\n\n" + else: + content += f"### ***var*** `{attr.name}: {attr.type} = {attr.value}`\n\n" + + attr.docstring = attr.docstring.replace("\n", "\n\n") + content += f"{attr.docstring}\n\n" + + return content + + +def generate_docs(module_folder: str, output_dir: str, with_top: bool = False, lang: str = "zh-CN", ignored_paths=None): + """ + 生成文档 + Args: + module_folder: 模块文件夹 + output_dir: 输出文件夹 + with_top: 是否包含顶层文件夹 False时例如docs/api/module_a, docs/api/module_b, True时例如docs/api/module/module_a.md, docs/api/module/module_b.md + ignored_paths: 忽略的路径 + lang: 语言 + """ + if ignored_paths is None: + ignored_paths = [] + file_data: dict[str, str] = {} # 路径 -> 字串 + + file_list = get_file_list(module_folder) + + # 清理输出目录 + shutil.rmtree(output_dir, ignore_errors=True) + os.mkdir(output_dir) + + replace_data = { + "__init__": "README", + ".py" : ".md", + } + + for pyfile_path in file_list: + if any(ignored_path.replace("\\", "/") in pyfile_path.replace("\\", "/") for ignored_path in ignored_paths): + continue + + no_module_name_pyfile_path = get_relative_path(module_folder, pyfile_path) # 去头路径 + + # markdown相对路径 + rel_md_path = pyfile_path if with_top else no_module_name_pyfile_path + for rk, rv in replace_data.items(): + rel_md_path = rel_md_path.replace(rk, rv) + + abs_md_path = os.path.join(output_dir, rel_md_path) + + # 获取模块信息 + module_info = get_module_info_normal(pyfile_path) + + # 生成markdown + + if "README" in abs_md_path: + front_matter = { + "title" : module_info.module_path.replace(".__init__", "").replace("_", "\\n"), + "index" : "true", + "icon" : "laptop-code", + "category": "API" + } + else: + front_matter = { + "title" : module_info.module_path.replace("_", "\\n"), + "order" : "1", + "icon" : "laptop-code", + "category": "API" + } + + md_content = generate_markdown(module_info, front_matter) + print(f"Generate {pyfile_path} -> {abs_md_path}") + file_data[abs_md_path] = md_content + + write_to_files(file_data) + + +# 入口脚本 +if __name__ == '__main__': + # 这里填入你的模块路径 + generate_docs('liteyuki', 'docs/dev/api', with_top=False, ignored_paths=["liteyuki/plugins"], lang="zh-CN") + generate_docs('liteyuki', 'docs/en/dev/api', with_top=False, ignored_paths=["liteyuki/plugins"], lang="en") diff --git a/liteyuki/plugin/__init__.py b/liteyuki/plugin/__init__.py index 7bb7edf..370b307 100644 --- a/liteyuki/plugin/__init__.py +++ b/liteyuki/plugin/__init__.py @@ -1,11 +1,12 @@ -from liteyuki.plugin.model import Plugin, PluginMetadata +from liteyuki.plugin.model import Plugin, PluginMetadata, PluginType from liteyuki.plugin.load import load_plugin, load_plugins, _plugins __all__ = [ - "PluginMetadata", - "Plugin", - "load_plugin", - "load_plugins", + "PluginMetadata", + "Plugin", + "PluginType", + "load_plugin", + "load_plugins", ] diff --git a/liteyuki/plugin/load.py b/liteyuki/plugin/load.py index 3ef2933..97fa765 100644 --- a/liteyuki/plugin/load.py +++ b/liteyuki/plugin/load.py @@ -10,14 +10,12 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved """ import os import traceback +from importlib import import_module from pathlib import Path from typing import Optional -from nonebot import logger - -from liteyuki.plugin.model import Plugin, PluginMetadata -from importlib import import_module - +from liteyuki.log import logger +from liteyuki.plugin.model import Plugin, PluginMetadata, PluginType from liteyuki.utils import path_to_module_name _plugins: dict[str, Plugin] = {} @@ -25,6 +23,7 @@ _plugins: dict[str, Plugin] = {} __all__ = [ "load_plugin", "load_plugins", + "_plugins", ] @@ -35,45 +34,92 @@ def load_plugin(module_path: str | Path) -> Optional[Plugin]: module_path: 插件名称 `path.to.your.plugin` 或插件路径 `pathlib.Path(path/to/your/plugin)` """ - module_path = path_to_module_name(Path(module_path)) if isinstance(module_path, Path) else module_path + module_path = ( + path_to_module_name(Path(module_path)) + if isinstance(module_path, Path) + else module_path + ) try: module = import_module(module_path) _plugins[module.__name__] = Plugin( name=module.__name__, module=module, module_name=module_path, - metadata=module.__dict__.get("__plugin_metadata__", None) ) + if module.__dict__.get("__plugin_metadata__", None): + metadata: "PluginMetadata" = module.__dict__["__plugin_metadata__"] + display_name = module.__name__.split(".")[-1] + elif module.__dict__.get("__liteyuki_plugin_meta__", None): + metadata: "PluginMetadata" = module.__dict__["__liteyuki_plugin_meta__"] + display_name = format_display_name( + f"{metadata.name}({module.__name__.split('.')[-1]})", metadata.type + ) + elif module.__dict__.get("__plugin_meta__", None): + metadata: "PluginMetadata" = module.__dict__["__plugin_meta__"] + display_name = format_display_name( + f"{metadata.name}({module.__name__.split('.')[-1]})", metadata.type + ) + else: + + logger.opt(colors=True).warning( + f'轻雪插件 "{module.__name__}" 的元信息未指定,将采用空的元信息' + ) + + metadata = PluginMetadata( + name=module.__name__, + ) + display_name = module.__name__.split(".")[-1] + + _plugins[module.__name__].metadata = metadata + logger.opt(colors=True).success( - f'成功加载 轻雪插件 "{module.__name__.split(".")[-1]}"' + f'成功加载轻雪插件 "{display_name}"' ) return _plugins[module.__name__] except Exception as e: logger.opt(colors=True).success( - f'未能加载 轻雪插件 "{module_path}"' + f'无法加载轻雪插件 "{module_path}"' ) traceback.print_exc() return None -def load_plugins(*plugin_dir: str) -> set[Plugin]: +def load_plugins(*plugin_dir: str, ignore_warning: bool = True) -> set[Plugin]: """导入文件夹下多个插件 参数: plugin_dir: 文件夹路径 + ignore_warning: 是否忽略警告,通常是目录不存在或目录为空 """ plugins = set() for dir_path in plugin_dir: # 遍历每一个文件夹下的py文件和包含__init__.py的文件夹,不递归 + if not os.path.exists(dir_path): + if not ignore_warning: + logger.warning(f"插件目录 '{dir_path}' 不存在") + continue + + if not os.listdir(dir_path): + if not ignore_warning: + logger.warning(f"插件目录 '{dir_path}' 为空") + continue + + if not os.path.isdir(dir_path): + if not ignore_warning: + logger.warning(f"本应是插件目录的路径 '{dir_path}' 并非如此") + continue + for f in os.listdir(dir_path): path = Path(os.path.join(dir_path, f)) module_name = None - if os.path.isfile(path) and f.endswith('.py') and f != '__init__.py': + if os.path.isfile(path) and f.endswith(".py") and f != "__init__.py": module_name = f"{path_to_module_name(Path(dir_path))}.{f[:-3]}" - elif os.path.isdir(path) and os.path.exists(os.path.join(path, '__init__.py')): + elif os.path.isdir(path) and os.path.exists( + os.path.join(path, "__init__.py") + ): module_name = path_to_module_name(path) if module_name: @@ -81,3 +127,27 @@ def load_plugins(*plugin_dir: str) -> set[Plugin]: if _plugins.get(module_name): plugins.add(_plugins[module_name]) return plugins + + +def format_display_name(display_name: str, plugin_type: PluginType) -> str: + """ + 设置插件名称颜色,根据不同类型插件设置颜色 + Args: + display_name: 插件名称 + plugin_type: 插件类型 + + Returns: + str: 设置后的插件名称 name + """ + color = "y" + match plugin_type: + case PluginType.APPLICATION: + color = "m" + case PluginType.TEST: + color = "g" + case PluginType.MODULE: + color = "e" + case PluginType.SERVICE: + color = "c" + + return f"<{color}>{display_name} [{plugin_type.name}]" diff --git a/liteyuki/plugin/manager.py b/liteyuki/plugin/manager.py index ca0edbb..9107775 100644 --- a/liteyuki/plugin/manager.py +++ b/liteyuki/plugin/manager.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- """ -Copyright (C) 2020-2024 LiteyukiStudio. All rights reserved - -版权所有 © 2020-2024 神羽SnowyKami & 金羿Eilles with LiteyukiStudio & TriM Org. -保留所有权利 +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved @Time : 2024/7/23 下午11:59 @Author : snowykami diff --git a/liteyuki/plugin/model.py b/liteyuki/plugin/model.py index a741415..cb066c3 100644 --- a/liteyuki/plugin/model.py +++ b/liteyuki/plugin/model.py @@ -8,22 +8,61 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved @File : model.py @Software: PyCharm """ +from enum import Enum from types import ModuleType -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel +class PluginType(Enum): + """ + 插件类型枚举值 + """ + APPLICATION = "application" + """应用端:例如NoneBot""" + + SERVICE = "service" + """服务端:例如AI绘画后端""" + + MODULE = "module" + """模块:导出对象给其他插件使用""" + + UNCLASSIFIED = "unclassified" + """未分类:默认值""" + + TEST = "test" + """测试:测试插件""" + + class PluginMetadata(BaseModel): """ 轻雪插件元数据,由插件编写者提供,name为必填项 + Attributes: + ---------- + + name: str + 插件名称 + description: str + 插件描述 + usage: str + 插件使用方法 + type: str + 插件类型 + author: str + 插件作者 + homepage: str + 插件主页 + extra: dict[str, Any] + 额外信息 """ name: str description: str = "" usage: str = "" - type: str = "" + type: PluginType = PluginType.UNCLASSIFIED + author: str = "" homepage: str = "" - running_in_main: bool = True # 是否在主进程运行 + extra: dict[str, Any] = {} class Plugin(BaseModel): diff --git a/liteyuki/plugins/__init__.py b/liteyuki/plugins/__init__.py new file mode 100644 index 0000000..a0fa114 --- /dev/null +++ b/liteyuki/plugins/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +此模块为内置插件文件夹,用于存放内置插件。 +This module is the built-in plugin folder, used to store built-in plugins. +""" diff --git a/liteyuki/plugins/lifespan_monitor.py b/liteyuki/plugins/lifespan_monitor.py deleted file mode 100644 index 964f2c5..0000000 --- a/liteyuki/plugins/lifespan_monitor.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved -# -# @Time : 2024/7/22 上午11:25 -# @Author : snowykami -# @Email : snowykami@outlook.com -# @File : asa.py -# @Software: PyCharm -import asyncio - -from liteyuki.plugin import PluginMetadata -from liteyuki import get_bot, logger -from liteyuki.comm.channel import get_channel - -__plugin_meta__ = PluginMetadata( - name="lifespan_monitor", -) - -bot = get_bot() -nbp_chan = get_channel("nonebot-passive") -mbp_chan = get_channel("melobot-passive") - - -@bot.on_before_start -def _(): - logger.info("生命周期监控器:启动前") - - -@bot.on_before_shutdown -def _(): - print(get_channel("main")) - logger.info("生命周期监控器:停止前") - - -@bot.on_before_restart -def _(): - logger.info("生命周期监控器:重启前") - - -@bot.on_after_start -def _(): - logger.info("生命周期监控器:启动后") - - -@bot.on_after_start -async def _(): - logger.info("生命周期监控器:启动后") - - - -# @mbp_chan.on_receive() -# @nbp_chan.on_receive() -# async def _(data): -# print("主进程收到数据", data) diff --git a/liteyuki/plugins/liteecho.py b/liteyuki/plugins/liteecho.py new file mode 100644 index 0000000..1f70582 --- /dev/null +++ b/liteyuki/plugins/liteecho.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/22 下午12:31 +@Author : snowykami +@Email : snowykami@outlook.com +@File : liteecho.py +@Software: PyCharm +""" + +from liteyuki.message.on import on_startswith +from liteyuki.message.event import MessageEvent +from liteyuki.message.rule import is_su_rule + + +@on_startswith(["ryounecho", "ryeco"], rule=is_su_rule).handle() +async def liteecho(event: MessageEvent): + event.reply(event.raw_message.strip()[8:].strip()) diff --git a/liteyuki/plugins/plugin_loader/__init__.py b/liteyuki/plugins/plugin_loader/__init__.py new file mode 100644 index 0000000..84ea543 --- /dev/null +++ b/liteyuki/plugins/plugin_loader/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/11 下午10:02 +@Author : snowykami +@Email : snowykami@outlook.com +@File : __init__.py.py +@Software: PyCharm +""" +from liteyuki import get_config, load_plugin +from liteyuki.plugin import PluginMetadata, load_plugins, PluginType + +__plugin_meta__ = PluginMetadata( + name="外部轻雪插件加载器", + description="插件加载器,用于加载轻雪原生插件", + type=PluginType.SERVICE +) + + +def default_plugins_loader(): + """ + 默认插件加载器,应在初始化时调用 + """ + for plugin in get_config("liteyuki.plugins", []): + load_plugin(plugin) + + for plugin_dir in get_config("liteyuki.plugin_dirs", ["src/liteyuki_plugins"]): + load_plugins(plugin_dir) + + +default_plugins_loader() diff --git a/liteyuki/plugins/resource_loader/__init__.py b/liteyuki/plugins/resource_loader/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/liteyuki/utils.py b/liteyuki/utils.py index 10e7ff3..629f5d7 100644 --- a/liteyuki/utils.py +++ b/liteyuki/utils.py @@ -4,11 +4,15 @@ """ import asyncio import inspect +import multiprocessing +import threading from pathlib import Path from typing import Any, Callable, Coroutine from liteyuki.log import logger +IS_MAIN_PROCESS = multiprocessing.current_process().name == "MainProcess" + def is_coroutine_callable(call: Callable[..., Any]) -> bool: """ @@ -39,7 +43,7 @@ def run_coroutine(*coro: Coroutine): # 检测是否有现有的事件循环 try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() if loop.is_running(): # 如果事件循环正在运行,创建任务 for c in coro: @@ -59,6 +63,18 @@ def run_coroutine(*coro: Coroutine): logger.error(f"协程异常:{e}") +def run_coroutine_in_thread(*coro: Coroutine): + """ + 在新线程中运行协程 + Args: + coro: + + Returns: + + """ + threading.Thread(target=run_coroutine, args=coro, daemon=True).start() + + def path_to_module_name(path: Path) -> str: """ 转换路径为模块名 @@ -72,3 +88,19 @@ def path_to_module_name(path: Path) -> str: return ".".join(rel_path.parts[:-1]) else: return ".".join(rel_path.parts[:-1] + (rel_path.stem,)) + + +def async_wrapper(func: Callable[..., Any]) -> Callable[..., Coroutine]: + """ + 异步包装器 + Args: + func: Sync Callable + Returns: + Coroutine: Asynchronous Callable + """ + + async def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + wrapper.__signature__ = inspect.signature(func) + return wrapper diff --git a/main.py b/main.py index 7efe027..93bc51c 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,10 @@ +""" +启动脚本,会执行一些启动的操作,比如加载配置文件,初始化 bot 实例等。 +""" from liteyuki import LiteyukiBot -from liteyuki.config import load_from_yaml +from liteyuki.config import load_config_in_default + if __name__ == "__main__": - bot = LiteyukiBot(**load_from_yaml("config.yml")) + bot = LiteyukiBot(**load_config_in_default(no_waring=True)) bot.run() diff --git a/pyproject.toml b/pyproject.toml index e65327c..f131770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,58 +1,44 @@ # PEP 621 project metadata # See https://www.python.org/dev/peps/pep-0621/ -# This file is for project use, but don`t use with nb-cli -# 此文件为项目所用,请不要和nb-cli一起使用以防被修改 -[tool.poetry] +# This file is liteyuki framework use only, don`t use it with applications or nb-cli. +# 此文件仅供 liteyuki 框架使用,请勿用于应用程序及nb-cli,请使用pip进行安装。 +[project] name = "ryoun-trim" -version = "0" -description = "based on liteyuki6" -authors = ["金羿Eilles"] -license = "MIT & LSO" -package-mode = false +dynamic = ["version"] +description = "A lightweight bot process management framework and application." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Eilles", email = "EillesWan@outlook.com" }, + { name = "TriM-Org.", email = "TriM-Organization@hotmail.com" }, + { name = "snowykami", email = "snowykami@outlook.com" }, + { name = "LiteyukiStudio", email = "studio@liteyuki.icu" }, +] +license = { text = "汉钰律许可协议 第一版" } - -[tool.poetry.dependencies] -python = "^3.10" -aiofiles = "~23.2.1" -aiohttp = "~3.9.3" -aiosqlite3 = "~0.3.0" -colored = "~2.2.4" -fastapi = "~0.110.0" -GitPython = "~3.1.42" -httpx = "~0.27.0" -importlib_metadata = "~7.0.2" -jieba = "~0.42.1" -loguru = "~0.7.2" -nb-cli = "~1.4.1" -nonebot-adapter-onebot = "~2.4.3" -nonebot-adapter-satori = "~0.11.5" -nonebot-plugin-alconna = "~0.46.3" -nonebot-plugin-apscheduler = "~0.4.0" -nonebot-plugin-htmlrender = "~0.3.1" -nonebot2 = { version = "~2.3.0", extras = ["fastapi", "httpx", "websockets"] } -numpy = "<2.0.0" -packaging = "~23.1" -psutil = "~5.9.8" -py-cpuinfo = "~9.0.0" -pydantic = "~2.7.0" -Pygments = "~2.17.2" -python-dotenv = "~1.0.1" -pytest = "~8.3.1" -pytz = "~2024.1" -PyYAML = "~6.0.1" -requests = "~2.31.0" -starlette = "~0.36.3" -watchdog = "~4.0.0" - - -[[tool.poetry.source]] -name = "tuna" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" - -[tool.nonebot] +dependencies = [ + "loguru~=0.7.2", + "pydantic==2.8.2", + "PyYAML==6.0.2", + "toml==0.10.2", + "watchdog==4.0.1", + "pdm-backend==2.3.3" +] [project.urls] -homepage = "https://bot.liteyuki.icu" -repository = "https://gitee.com/TriM-Organization/LiteyukiBot-TriM" -documentation = "https://bot.liteyuki.icu" +Homepage = "https://bot.liteyuki.icu" +Repository = "https://gitee.com/TriM-Organization/LiteyukiBot-TriM" +"Issue Tracker" = "https://github.com/LiteyukiStudio/LiteyukiBot/issues/new?assignees=&labels=&projects=&template=%E9%97%AE%E9%A2%98%E5%8F%8D%E9%A6%88.md&title=" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[tool.pdm.build] +includes = ["liteyuki/", "LICENSE", "README.md", "LICENSE.MD"] +excludes = ["tests/", "docs/", "src/"] + +[tool.pdm.version] +source = "file" +path = "liteyuki/__init__.py" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index da4db6d..dbc9c6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,35 +1,34 @@ -aiohttp~=3.9.3 -aiofiles~=23.2.1 -colored~=2.2.4 -GitPython~=3.1.42 -httpx~=0.27.0 -nb-cli~=1.4.1 -nonebot2[fastapi,httpx,websockets]~=2.3.0 -nonebot-plugin-htmlrender~=0.3.1 -nonebot-adapter-onebot~=2.4.3 -nonebot-plugin-alconna~=0.46.3 -nonebot_plugin_apscheduler~=0.4.0 -nonebot-adapter-satori~=0.11.5 +aiohttp>=3.9.3 +aiofiles>=23.2.1 +colored>=2.2.4 +GitPython>=3.1.43 +httpx>=0.27.0 +nonebot-plugin-htmlrender>=0.1.0 +nonebot2[fastapi,httpx,websockets]>=2.3.3 +nonebot-adapter-onebot>=2.4.3 +nonebot-plugin-alconna>=0.46.3 +nonebot_plugin_apscheduler>=0.4.0 +nonebot-adapter-satori>=0.11.5 +# pyppeteer>=2.0.0 +markdown>=3.3.6 +zhDateTime>=1.1.1 numpy<2.0.0 -packaging~=23.1 -psutil~=5.9.8 -py-cpuinfo~=9.0.0 -pydantic~=2.7.0 -Pygments~=2.17.2 -pytz~=2024.1 -PyYAML~=6.0.1 -starlette~=0.36.3 -loguru~=0.7.2 -importlib_metadata~=7.0.2 -requests~=2.31.0 -watchdog~=4.0.0 -pillow~=10.0.0 -jieba~=0.42.1 -aiosqlite3~=0.3.0 -fastapi~=0.110.0 -python-dotenv~=1.0.1 +packaging>=23.1 +psutil>=5.9.8 +py-cpuinfo>=9.0.0 +Pygments>=2.17.2 +pyppeteer>=2.0.0 +pytz>=2024.1 +PyYAML>=6.0.1 +pillow>=10.0.0 +toml>=0.10.2 +importlib_metadata>=7.0.2 +watchdog>=4.0.0 +jieba>=0.42.1 +python-dotenv>=1.0.1 nonebot_plugin_session pypinyin -zhDateTime>=1.0.3 Musicreater>=2.2.0 -librosa==0.10.1 \ No newline at end of file +librosa==0.10.1 +TrimMCStruct +brotli \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/liteyuki_main/__init__.py b/src/liteyuki_main/__init__.py index 24324a0..8321e08 100644 --- a/src/liteyuki_main/__init__.py +++ b/src/liteyuki_main/__init__.py @@ -2,7 +2,6 @@ from nonebot.plugin import PluginMetadata from .core import * from .loader import * -from .dev import * __author__ = "snowykami" __plugin_meta__ = PluginMetadata( @@ -18,29 +17,6 @@ __plugin_meta__ = PluginMetadata( from ..utils.base.language import Language, get_default_lang_code -print( - "\033[34m" - + r""" - ▅▅▅▅▅▅▅▅▅▅▅▅▅▅██ ▅▅▅▅▅▅▅▅▅▅▅▅▅▅██ ██ ▅▅▅▅▅▅▅▅▅▅█™ - ▛ ██ ██ ▛ ██ ███ ██ ██ - ██ ██ ███████████████ ██ ████████▅ ██ - ███████████████ ██ ███ ██ ██ - ██ ██ ▅██████████████▛ ██ ████████████ - ██ ██ ███ ███ - ████████████████ ██▅ ███ ██ ▅▅▅▅▅▅▅▅▅▅▅██ - ███ █ ▜███████ ██ ███ ██ ██ ██ ██ - ███ ███ █████▛ ██ ██ ██ ██ ██ - ███ ██ ███ █ ██ ██ ██ ██ ██ - ███ █████ ██████ ███ ██████████████ - 商标标记 © 2024 金羿Eilles - 版权所有 © 2020-2024 神羽SnowyKami & 金羿Eilles\\ - with LiteyukiStudio & TriM Org. - 保留所有权利 -""" - + "\033[0m" -) - - sys_lang = Language(get_default_lang_code()) nonebot.logger.info( sys_lang.get("main.current_language", LANG=sys_lang.get("language.name")) diff --git a/src/liteyuki_main/core.py b/src/liteyuki_main/core.py index 8193e5f..6905ec5 100644 --- a/src/liteyuki_main/core.py +++ b/src/liteyuki_main/core.py @@ -1,44 +1,43 @@ -import base64 import time -from typing import Any, AnyStr +from typing import AnyStr + +import time +from typing import AnyStr import nonebot import pip -from nonebot import Bot, get_driver, require # type: ignore +from nonebot import get_driver, require from nonebot.adapters import onebot, satori -from nonebot.adapters.onebot.v11 import Message, escape, unescape -from nonebot.exception import MockApiException +from nonebot.adapters.onebot.v11 import Message, unescape from nonebot.internal.matcher import Matcher from nonebot.permission import SUPERUSER +# from src.liteyuki.core import Reloader from src.utils import event as event_utils, satori_utils -from src.utils.base.config import get_config, load_from_yaml -from src.utils.base.data_manager import StoredConfig, TempConfig, common_db +from src.utils.base.config import get_config +from src.utils.base.data_manager import TempConfig, common_db from src.utils.base.language import get_user_lang from src.utils.base.ly_typing import T_Bot, T_MessageEvent from src.utils.message.message import MarkdownMessage as md, broadcast_to_superusers - -from .api import update_liteyuki -from ..utils.base import reload -from ..utils.base.ly_function import get_function +from .api import update_liteyuki # type: ignore +from ..utils.base import reload # type: ignore +from ..utils.base.ly_function import get_function # type: ignore +from ..utils.message.html_tool import md_to_pic require("nonebot_plugin_alconna") require("nonebot_plugin_apscheduler") from nonebot_plugin_alconna import ( + UniMessage, on_alconna, Alconna, Args, - Subcommand, Arparma, MultiVar, ) from nonebot_plugin_apscheduler import scheduler -driver = get_driver() -markdown_image = common_db.where_one(StoredConfig(), default=StoredConfig()).config.get( - "markdown_image", False -) +driver = get_driver() @on_alconna( @@ -50,8 +49,8 @@ markdown_image = common_db.where_one(StoredConfig(), default=StoredConfig()).con ).handle() # Satori OK async def _(bot: T_Bot, matcher: Matcher, result: Arparma): - if result.main_args.get("text"): - await matcher.finish(Message(unescape(result.main_args.get("text")))) # type: ignore + if text := result.main_args.get("text"): + await matcher.finish(Message(unescape(text))) else: await matcher.finish(f"君安!灵温向你问好~\n此机 {bot.self_id}") @@ -72,7 +71,7 @@ async def _(bot: T_Bot, matcher: Matcher, result: Arparma): aliases={"更新灵温"}, command=Alconna("update-ryoun"), permission=SUPERUSER ).handle() # Satori OK -async def _(bot: T_Bot, event: T_MessageEvent): +async def _(bot: T_Bot, event: T_MessageEvent, matcher: Matcher): # 使用git pull更新 ulang = get_user_lang( @@ -84,7 +83,9 @@ async def _(bot: T_Bot, event: T_MessageEvent): btn_restart = md.btn_cmd(ulang.get("liteyuki.restart_now"), "reload-liteyuki") pip.main(["install", "-r", "requirements.txt"]) reply += f"{ulang.get('liteyuki.update_restart', RESTART=btn_restart)}" - await md.send_md(reply, bot, event=event, at_sender=False) + # await md.send_md(reply, bot) + img_bytes = await md_to_pic(reply) + await UniMessage.send(UniMessage.image(raw=img_bytes)) @on_alconna( @@ -115,108 +116,9 @@ async def _(matcher: Matcher, bot: T_Bot, event: T_MessageEvent): ) common_db.save(temp_data) - reload() -@on_alconna( - aliases={"配置"}, - command=Alconna( - "config", - Subcommand( - "set", - Args["key", str]["value", Any], - alias=["设置"], - ), - Subcommand("get", Args["key", str, None], alias=["查询", "获取"]), - Subcommand("remove", Args["key", str], alias=["删除"]), - ), - permission=SUPERUSER, -).handle() -# Satori OK -async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, matcher: Matcher): - ulang = get_user_lang(str(event_utils.get_user_id(event))) - stored_config: StoredConfig = common_db.where_one( - StoredConfig(), default=StoredConfig() - ) - if result.subcommands.get("set"): - key, value = result.subcommands.get("set").args.get( - "key" - ), result.subcommands.get("set").args.get("value") - try: - value = eval(value) - except: - pass - stored_config.config[key] = value - common_db.save(stored_config) - await matcher.finish( - f"{ulang.get('liteyuki.config_set_success', KEY=key, VAL=value)}" - ) - elif result.subcommands.get("get"): - key = result.subcommands.get("get").args.get("key") - file_config = load_from_yaml("config.yml") - reply = f"{ulang.get('liteyuki.current_config')}" - if key: - reply += f"```dotenv\n{key}={file_config.get(key, stored_config.config.get(key))}\n```" - else: - reply = f"{ulang.get('liteyuki.current_config')}" - reply += f"\n{ulang.get('liteyuki.static_config')}\n```dotenv" - for k, v in file_config.items(): - reply += f"\n{k}={v}" - reply += "\n```" - if len(stored_config.config) > 0: - reply += f"\n{ulang.get('liteyuki.stored_config')}\n```dotenv" - for k, v in stored_config.config.items(): - reply += f"\n{k}={v} {type(v)}" - reply += "\n```" - await md.send_md(reply, bot, event=event) - elif result.subcommands.get("remove"): - key = result.subcommands.get("remove").args.get("key") - if key in stored_config.config: - stored_config.config.pop(key) - common_db.save(stored_config) - await matcher.finish( - f"{ulang.get('liteyuki.config_remove_success', KEY=key)}" - ) - else: - await matcher.finish(f"{ulang.get('liteyuki.invalid_command', TEXT=key)}") - - -@on_alconna( - aliases={"切换图片模式"}, command=Alconna("switch-image-mode"), permission=SUPERUSER -).handle() -# Satori OK -async def _(event: T_MessageEvent, matcher: Matcher): - global markdown_image - # 切换图片模式,False以图片形式发送,True以markdown形式发送 - ulang = get_user_lang(str(event_utils.get_user_id(event))) - stored_config: StoredConfig = common_db.where_one( - StoredConfig(), default=StoredConfig() - ) - stored_config.config["markdown_image"] = not stored_config.config.get( - "markdown_image", False - ) - markdown_image = stored_config.config["markdown_image"] - common_db.save(stored_config) - await matcher.finish( - ulang.get( - "liteyuki.image_mode_on" - if stored_config.config["markdown_image"] - else "liteyuki.image_mode_off" - ) - ) - - -# @on_alconna( -# command=Alconna( -# "liteyuki-docs", -# ), -# aliases={"轻雪文档"}, -# ).handle() -# # Satori OK -# async def _(matcher: Matcher): -# await matcher.finish("https://bot.liteyuki.icu/usage") - @on_alconna( command=Alconna( @@ -318,62 +220,6 @@ async def _(result: Arparma, bot: T_Bot, event: T_MessageEvent, matcher: Matcher await matcher.finish(f"API: {api_name}\n\nArgs: \n{args_show}\n\nResult: {result}") -# system hook -@Bot.on_calling_api # 图片模式检测 -async def test_for_md_image(bot: T_Bot, api: str, data: dict): - # 截获大图发送,转换为markdown发送 - if ( - api in ["send_msg", "send_private_msg", "send_group_msg"] - and markdown_image - and data.get("user_id") != bot.self_id - ): - if ( - api == "send_msg" - and data.get("message_type") == "private" - or api == "send_private_msg" - ): - session_type = "private" - session_id = data.get("user_id") - elif ( - api == "send_msg" - and data.get("message_type") == "group" - or api == "send_group_msg" - ): - session_type = "group" - session_id = data.get("group_id") - else: - return - if ( - len(data.get("message", [])) == 1 - and data["message"][0].get("type") == "image" - ): - file: str = data["message"][0].data.get("file") - # file:// http:// base64:// - if file.startswith("http"): - result = await md.send_md( - await md.image_async(file), - bot, - message_type=session_type, - session_id=session_id, - ) - elif file.startswith("file"): - file = file.replace("file://", "") - result = await md.send_image( - open(file, "rb").read(), - bot, - message_type=session_type, - session_id=session_id, - ) - elif file.startswith("base64"): - file_bytes = base64.b64decode(file.replace("base64://", "")) - result = await md.send_image( - file_bytes, bot, message_type=session_type, session_id=session_id - ) - else: - return - raise MockApiException(result=result) - - @driver.on_startup async def on_startup(): temp_data = common_db.where_one(TempConfig(), default=TempConfig()) @@ -426,6 +272,14 @@ async def _(bot: T_Bot): group_id=reload_session_id, message=return_msg, ) + elif isinstance(bot, onebot.v12.Bot): + await bot.send_msg( + message_type=reload_session_type, + user_id=reload_session_id, + group_id=reload_session_id, + message=return_msg, + detail_type="group", + ) else: await bot.call_api( "send_msg", @@ -445,7 +299,6 @@ async def every_day_update(): if result: await broadcast_to_superusers(f"灵温已更新:```\n{logs}\n```") nonebot.logger.info(f"灵温已更新:{logs}") - # ProcessingManager.restart(3) reload() else: nonebot.logger.info(logs) diff --git a/src/liteyuki_main/dev.py b/src/liteyuki_main/dev.py deleted file mode 100644 index 49f90ca..0000000 --- a/src/liteyuki_main/dev.py +++ /dev/null @@ -1,33 +0,0 @@ -import nonebot -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler - -from liteyuki.bot import get_bot -from src.utils.base import reload -from src.utils.base.config import get_config -from src.utils.base.resource import load_resources - -if get_config("debug", False): - - liteyuki_bot = get_bot() - - res_directories = ( - "src/resources", - "resources", - ) - - class ResourceModifiedHandler(FileSystemEventHandler): - """ - Handler for resource file changes - """ - - def on_modified(self, event): - nonebot.logger.info(f"资源 {event.src_path} 变更,重载资源包……") - load_resources() - - resource_modified_handle = ResourceModifiedHandler() - - observer = Observer() - for directory in res_directories: - observer.schedule(resource_modified_handle, directory, recursive=True) - observer.start() diff --git a/src/liteyuki_main/loader.py b/src/liteyuki_main/loader.py index 199c9d3..91df45e 100644 --- a/src/liteyuki_main/loader.py +++ b/src/liteyuki_main/loader.py @@ -8,15 +8,10 @@ from src.utils.base.data_manager import InstalledPlugin, plugin_db from src.utils.base.resource import load_resources from src.utils.message.tools import check_for_package -from liteyuki import get_bot, chan - -from nonebot_plugin_apscheduler import scheduler - load_resources() init_log() driver = get_driver() -liteyuki_bot = get_bot() @driver.on_startup @@ -32,7 +27,7 @@ async def load_plugins(): for installed_plugin in installed_plugins: if not check_for_package(installed_plugin.module_name): nonebot.logger.error( - f"插件 {installed_plugin.module_name} 在加载列表中但未安装。请使用超管账户对机器人发送 `npm fixup` 以重新安装。" + f"插件 {installed_plugin.module_name} 仍在加载列表中但未安装。" ) else: nonebot.load_plugin(installed_plugin.module_name) diff --git a/src/liteyuki_plugins/README.md b/src/liteyuki_plugins/README.md deleted file mode 100644 index aba0376..0000000 --- a/src/liteyuki_plugins/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# 说明 - -此目录为**轻雪插件**目录,非其他插件目录。 \ No newline at end of file diff --git a/src/liteyuki_plugins/hello_liteyuki.py b/src/liteyuki_plugins/hello_liteyuki.py new file mode 100644 index 0000000..86314c3 --- /dev/null +++ b/src/liteyuki_plugins/hello_liteyuki.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/20 上午5:12 +@Author : snowykami +@Email : snowykami@outlook.com +@File : liteyuki_reply.py +@Software: PyCharm +""" +from liteyuki.plugin import PluginMetadata, PluginType +from liteyuki.message.on import on_message +from liteyuki.message.event import MessageEvent + +__plugin_meta__ = PluginMetadata( + name="你好轻雪", + type=PluginType.APPLICATION +) + + +@on_message().handle() +async def _(event: MessageEvent): + if str(event.raw_message) == "你好轻雪": + event.reply("你好呀") diff --git a/src/liteyuki_plugins/lifespan_monitor.py b/src/liteyuki_plugins/lifespan_monitor.py new file mode 100644 index 0000000..68f3b81 --- /dev/null +++ b/src/liteyuki_plugins/lifespan_monitor.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved +# +# @Time : 2024/7/22 上午11:25 +# @Author : snowykami +# @Email : snowykami@outlook.com +# @File : asa.py +# @Software: PyCharm +import asyncio +import multiprocessing + +from liteyuki.plugin import PluginMetadata, PluginType +from liteyuki import get_bot, logger +from liteyuki.comm.channel import get_channel + +__plugin_meta__ = PluginMetadata( + name="生命周期日志", + type=PluginType.SERVICE, +) + +bot = get_bot() + + +@bot.on_before_start +def _(): + logger.info("生命周期监控器:准备启动") + + +@bot.on_before_process_shutdown +def _(name="name"): + logger.info("生命周期监控器:准备停止") + + +@bot.on_before_process_restart +def _(name="name"): + logger.info("生命周期监控器:准备重启") + + +@bot.on_after_start +async def _(): + logger.info("生命周期监控器:启动完成") diff --git a/src/liteyuki_plugins/liteyukibot_plugin_nonebot/__init__.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/__init__.py new file mode 100644 index 0000000..a0c6add --- /dev/null +++ b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/__init__.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/11 下午5:24 +@Author : snowykami +@Email : snowykami@outlook.com +@File : __init__.py.py +@Software: PyCharm +""" + +import nonebot +from liteyuki.utils import IS_MAIN_PROCESS +from liteyuki.plugin import PluginMetadata, PluginType +from .nb_utils import adapter_manager, driver_manager # type: ignore +from liteyuki.log import logger + +__plugin_meta__ = PluginMetadata( + name="NoneBot2启动器", + type=PluginType.APPLICATION, +) + + +def nb_run(*args, **kwargs): + """ + 初始化NoneBot并运行在子进程 + Args: + **kwargs: + + Returns: + """ + # 给子进程传递通道对象 + kwargs.update(kwargs.get("nonebot", {})) # nonebot配置优先 + nonebot.init(**kwargs) + + driver_manager.init(config=kwargs) + adapter_manager.init(kwargs) + adapter_manager.register() + + try: + # nonebot.load_plugin("nonebot-plugin-lnpm") # 尝试加载轻雪NoneBot插件加载器(Nonebot插件) + nonebot.load_plugin("src.liteyuki_main") # 尝试加载轻雪主插件(Nonebot插件) + except Exception as e: + pass + nonebot.run() + + +if IS_MAIN_PROCESS: + from liteyuki import get_bot + from .dev_reloader import * + + liteyuki = get_bot() + liteyuki.process_manager.add_target(name="nonebot", target=nb_run, args=(), kwargs=liteyuki.config) diff --git a/src/liteyuki_plugins/liteyukibot_plugin_nonebot/dev_reloader.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/dev_reloader.py new file mode 100644 index 0000000..8bebd08 --- /dev/null +++ b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/dev_reloader.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +NoneBot 开发环境重载监视器 +""" +import os.path + +from liteyuki.dev import observer +from liteyuki import get_bot, logger +from liteyuki.utils import IS_MAIN_PROCESS +from watchdog.events import FileSystemEvent + + +liteyuki = get_bot() + +exclude_extensions = (".pyc", ".pyo") + + +@observer.on_file_system_event( + directories=("src/nonebot_plugins",), + event_filter=lambda event: not event.src_path.endswith(exclude_extensions) and ("__pycache__" not in event.src_path ) and os.path.isfile(event.src_path) +) +def restart_nonebot_process(event: FileSystemEvent): + logger.debug(f"文件 {event.src_path} 已更新,正在重载 nonebot") + liteyuki.restart_process("nonebot") \ No newline at end of file diff --git a/liteyuki/core/nb/adapter_manager/__init__.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/__init__.py similarity index 100% rename from liteyuki/core/nb/adapter_manager/__init__.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/__init__.py diff --git a/liteyuki/core/nb/adapter_manager/onebot.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/onebot.py similarity index 100% rename from liteyuki/core/nb/adapter_manager/onebot.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/onebot.py diff --git a/liteyuki/core/nb/adapter_manager/satori.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/satori.py similarity index 71% rename from liteyuki/core/nb/adapter_manager/satori.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/satori.py index 0dfa23d..44edb8b 100644 --- a/liteyuki/core/nb/adapter_manager/satori.py +++ b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/adapter_manager/satori.py @@ -7,14 +7,14 @@ from nonebot.adapters import satori def init(config: dict): if config.get("satori", None) is None: - nonebot.logger.info("未查见 Satori 的配置文档,将跳过 Satori 初始化") + nonebot.logger.info("未寻得 Satori 设定信息,跳过初始化") return None satori_config = config.get("satori") if not satori_config.get("enable", False): - nonebot.logger.info("未启用 Satori ,将跳过 Satori 初始化") + nonebot.logger.info("Satori 未启用,跳过初始化") return None if os.getenv("SATORI_CLIENTS", None) is not None: - nonebot.logger.info("Satori 客户端已设入环境变量,跳过此步。") + nonebot.logger.info("Satori 客户端已在环境变量中配置,跳过初始化") os.environ["SATORI_CLIENTS"] = json.dumps(satori_config.get("hosts", []), ensure_ascii=False) config['satori_clients'] = satori_config.get("hosts", []) return diff --git a/liteyuki/core/nb/driver_manager/__init__.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/__init__.py similarity index 100% rename from liteyuki/core/nb/driver_manager/__init__.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/__init__.py diff --git a/liteyuki/core/nb/driver_manager/auto_set_env.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/auto_set_env.py similarity index 67% rename from liteyuki/core/nb/driver_manager/auto_set_env.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/auto_set_env.py index b79565e..982c988 100644 --- a/liteyuki/core/nb/driver_manager/auto_set_env.py +++ b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/auto_set_env.py @@ -9,12 +9,12 @@ from .defines import * def auto_set_env(config: dict): dotenv.load_dotenv(".env") if os.getenv("DRIVER", None) is not None: - nonebot.logger.info("Driver 已设入环境变量中,将跳过自动配置环节。") + nonebot.logger.info("Driver 已在环境变量中配置,跳过自动设定") return if config.get("satori", {'enable': False}).get("enable", False): os.environ["DRIVER"] = get_driver_string(ASGI_DRIVER, HTTPX_DRIVER, WEBSOCKETS_DRIVER) - nonebot.logger.info("已启用 Satori,将 driver 设为 ASGI+HTTPX+WEBSOCKETS") + nonebot.logger.info("启用 Satori,已设定 Driver 为 ASGI+HTTPX+WEBSOCKETS") else: os.environ["DRIVER"] = get_driver_string(ASGI_DRIVER) - nonebot.logger.info("已禁用 Satori,将 driver 设为 ASGI") + nonebot.logger.info("禁用 Satori,已设定 Driver 为 ASGI") return diff --git a/liteyuki/core/nb/driver_manager/defines.py b/src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/defines.py similarity index 100% rename from liteyuki/core/nb/driver_manager/defines.py rename to src/liteyuki_plugins/liteyukibot_plugin_nonebot/nb_utils/driver_manager/defines.py diff --git a/src/liteyuki_plugins/process_manager/__init__.py b/src/liteyuki_plugins/process_manager/__init__.py new file mode 100644 index 0000000..8c4be52 --- /dev/null +++ b/src/liteyuki_plugins/process_manager/__init__.py @@ -0,0 +1,8 @@ +from liteyuki.plugin import PluginMetadata, PluginType + +__plugin_meta__ = PluginMetadata( + name="进程管理器", + author="snowykami", + description="进程管理器,用于管理子进程", + type=PluginType.SERVICE +) diff --git a/liteyuki/plugins/register_service.py b/src/liteyuki_plugins/register_service.py similarity index 50% rename from liteyuki/plugins/register_service.py rename to src/liteyuki_plugins/register_service.py index 200bd15..2fd4c6d 100644 --- a/liteyuki/plugins/register_service.py +++ b/src/liteyuki_plugins/register_service.py @@ -11,21 +11,22 @@ Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved import json import os.path import platform - -import requests +from aiohttp import ClientSession from git import Repo -from liteyuki.plugin import PluginMetadata + +from liteyuki.plugin import PluginMetadata, PluginType from liteyuki import get_bot, logger __plugin_meta__ = PluginMetadata( name="注册服务", + type=PluginType.SERVICE ) liteyuki = get_bot() commit_hash = Repo(".").head.commit.hexsha -def register_bot(): +async def register_bot(): url = "https://api.liteyuki.icu/register" data = { "name" : "尹灵温|轻雪-睿乐", @@ -36,17 +37,19 @@ def register_bot(): "os" : f"{platform.system()} {platform.version()} {platform.machine()}" } try: - logger.info("正在等待 Liteyuki 注册服务器……") - resp = requests.post(url, json=data, timeout=(10, 15)) - if resp.status_code == 200: - data = resp.json() - if liteyuki_id := data.get("liteyuki_id"): - with open("data/liteyuki/liteyuki.json", "wb") as f: - f.write(json.dumps(data).encode("utf-8")) - logger.success("成功将 {} 注册到 Liteyuki 服务器".format(liteyuki_id)) - else: - raise ValueError(f"无法向 Liteyuki 服务器注册:{data}") - + logger.info("正在等待 Liteyuki 注册服务器…") + async with ClientSession() as session: + async with session.post(url, json=data, timeout=15) as resp: + if resp.status == 200: + data = await resp.json() + if liteyuki_id := data.get("liteyuki_id"): + with open("data/liteyuki/liteyuki.json", "wb") as f: + f.write(json.dumps(data).encode("utf-8")) + logger.success("成功将 {} 注册到 Liteyuki 服务器".format(liteyuki_id)) + else: + raise ValueError(f"无法向 Liteyuki 服务器注册:{data}") + else: + raise ValueError(f"无法向 Liteyuki 服务器注册:{resp.status}") except Exception as e: logger.warning(f"虽然向 Liteyuki 服务器注册失败,但无关紧要:{e}") @@ -54,4 +57,6 @@ def register_bot(): @liteyuki.on_before_start async def _(): if not os.path.exists("data/liteyuki/liteyuki.json"): - register_bot() + if not os.path.exists("data/liteyuki"): + os.makedirs("data/liteyuki") + await register_bot() diff --git a/src/liteyuki_plugins/resource_loader/__init__.py b/src/liteyuki_plugins/resource_loader/__init__.py new file mode 100644 index 0000000..9eaecf7 --- /dev/null +++ b/src/liteyuki_plugins/resource_loader/__init__.py @@ -0,0 +1,8 @@ +from liteyuki.plugin import PluginMetadata, PluginType + +__plugin_meta__ = PluginMetadata( + name="资源加载器", + author="snowykami", + description="进程管理器,用于管理子进程", + type=PluginType.SERVICE +) diff --git a/src/liteyuki_plugins/scheduled_tasks/__init__.py b/src/liteyuki_plugins/scheduled_tasks/__init__.py new file mode 100644 index 0000000..03cffe5 --- /dev/null +++ b/src/liteyuki_plugins/scheduled_tasks/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/15 下午11:29 +@Author : snowykami +@Email : snowykami@outlook.com +@File : __init__.py.py +@Software: PyCharm +""" +from liteyuki.plugin import PluginMetadata, PluginType + +from .divided_by_lifespan import * + +__plugin_meta__ = PluginMetadata( + name="计划任务", + description="计划任务插件,一些杂项任务的计划执行。", + type=PluginType.SERVICE +) diff --git a/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/__init__.py b/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/__init__.py new file mode 100644 index 0000000..a74b17b --- /dev/null +++ b/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/15 下午11:32 +@Author : snowykami +@Email : snowykami@outlook.com +@File : __init__.py +@Software: PyCharm +""" +from .after_start import * \ No newline at end of file diff --git a/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/after_start.py b/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/after_start.py new file mode 100644 index 0000000..bd3cc2a --- /dev/null +++ b/src/liteyuki_plugins/scheduled_tasks/divided_by_lifespan/after_start.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/15 下午11:32 +@Author : snowykami +@Email : snowykami@outlook.com +@File : after_start.py +@Software: PyCharm +""" +import time + +from liteyuki import get_bot +from liteyuki.comm.storage import shared_memory + +liteyuki = get_bot() + + +@liteyuki.on_before_start +def save_startup_timestamp(): + """ + 储存启动的时间戳 + """ + startup_timestamp = time.time() + shared_memory.set("startup_timestamp", startup_timestamp) diff --git a/src/nonebot_plugins/liteyuki_crt_utils/__init__.py b/src/nonebot_plugins/liteyuki_crt_utils/__init__.py deleted file mode 100644 index 386f3c6..0000000 --- a/src/nonebot_plugins/liteyuki_crt_utils/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -import multiprocessing - -from nonebot.plugin import PluginMetadata -from liteyuki.comm import get_channel -from .rt_guide import * -from .crt_matchers import * - -__plugin_meta__ = PluginMetadata( - name="CRT生成工具", - description="一些CRT牌子生成器", - usage="我觉得你应该会用", - type="application", - homepage="https://github.com/snowykami/LiteyukiBot", - extra={ - "liteyuki" : True, - "toggleable" : True, - "default_enable": True, - } -) - -# chan = get_channel("nonebot-passive") -# -# -# @chan.on_receive() -# async def _(d): -# print("CRT子进程接收到数据:", d) -# chan.send("CRT子进程已接收到数据") diff --git a/src/nonebot_plugins/liteyuki_crt_utils/canvas.py b/src/nonebot_plugins/liteyuki_crt_utils/canvas.py deleted file mode 100644 index 02e6e3d..0000000 --- a/src/nonebot_plugins/liteyuki_crt_utils/canvas.py +++ /dev/null @@ -1,575 +0,0 @@ -import os -import uuid -from typing import Tuple, Union, List - -import nonebot -from PIL import Image, ImageFont, ImageDraw - -default_color = (255, 255, 255, 255) -default_font = "resources/fonts/MiSans-Semibold.ttf" - - -def render_canvas_from_json(file: str, background: Image) -> "Canvas": - pass - - -class BasePanel: - def __init__(self, - uv_size: Tuple[Union[int, float], Union[int, float]] = (1.0, 1.0), - box_size: Tuple[Union[int, float], Union[int, float]] = (1.0, 1.0), - parent_point: Tuple[float, float] = (0.5, 0.5), - point: Tuple[float, float] = (0.5, 0.5)): - """ - :param uv_size: 底面板大小 - :param box_size: 子(自身)面板大小 - :param parent_point: 底面板锚点 - :param point: 子(自身)面板锚点 - """ - self.canvas: Canvas | None = None - self.uv_size = uv_size - self.box_size = box_size - self.parent_point = parent_point - self.point = point - self.parent: BasePanel | None = None - self.canvas_box: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) - # 此节点在父节点上的盒子 - self.box = ( - self.parent_point[0] - self.point[0] * self.box_size[0] / self.uv_size[0], - self.parent_point[1] - self.point[1] * self.box_size[1] / self.uv_size[1], - self.parent_point[0] + (1 - self.point[0]) * self.box_size[0] / self.uv_size[0], - self.parent_point[1] + (1 - self.point[1]) * self.box_size[1] / self.uv_size[1] - ) - - def load(self, only_calculate=False): - """ - 将对象写入画布 - 此处仅作声明 - 由各子类重写 - - :return: - """ - self.actual_pos = self.canvas_box - - def save_as(self, canvas_box, only_calculate=False): - """ - 此函数执行时间较长,建议异步运行 - :param only_calculate: - :param canvas_box 此节点在画布上的盒子,并不是在父节点上的盒子 - :return: - """ - for name, child in self.__dict__.items(): - # 此节点在画布上的盒子 - if isinstance(child, BasePanel) and name not in ["canvas", "parent"]: - child.parent = self - if isinstance(self, Canvas): - child.canvas = self - else: - child.canvas = self.canvas - dxc = canvas_box[2] - canvas_box[0] - dyc = canvas_box[3] - canvas_box[1] - child.canvas_box = ( - canvas_box[0] + dxc * child.box[0], - canvas_box[1] + dyc * child.box[1], - canvas_box[0] + dxc * child.box[2], - canvas_box[1] + dyc * child.box[3] - ) - child.load(only_calculate) - child.save_as(child.canvas_box, only_calculate) - - -class Canvas(BasePanel): - def __init__(self, base_img: Image.Image): - self.base_img = base_img - self.canvas = self - super(Canvas, self).__init__() - self.draw_line_list = [] - - def export(self, file, alpha=False): - self.base_img = self.base_img.convert("RGBA") - self.save_as((0, 0, 1, 1)) - draw = ImageDraw.Draw(self.base_img) - for line in self.draw_line_list: - draw.line(*line) - if not alpha: - self.base_img = self.base_img.convert("RGB") - self.base_img.save(file) - - def delete(self): - os.remove(self.file) - - def get_actual_box(self, path: str) -> Union[None, Tuple[float, float, float, float]]: - """ - 获取控件实际相对大小 - 函数执行时间较长 - - :param path: 控件路径 - :return: - """ - sub_obj = self - self.save_as((0, 0, 1, 1), True) - control_path = "" - for i, seq in enumerate(path.split(".")): - if seq not in sub_obj.__dict__: - raise KeyError(f"在{control_path}中找不到控件:{seq}") - control_path += f".{seq}" - sub_obj = sub_obj.__dict__[seq] - return sub_obj.actual_pos - - def get_actual_pixel_size(self, path: str) -> Union[None, Tuple[int, int]]: - """ - 获取控件实际像素长宽 - 函数执行时间较长 - :param path: 控件路径 - :return: - """ - sub_obj = self - self.save_as((0, 0, 1, 1), True) - control_path = "" - for i, seq in enumerate(path.split(".")): - if seq not in sub_obj.__dict__: - raise KeyError(f"在{control_path}中找不到控件:{seq}") - control_path += f".{seq}" - sub_obj = sub_obj.__dict__[seq] - dx = int(sub_obj.canvas.base_img.size[0] * (sub_obj.actual_pos[2] - sub_obj.actual_pos[0])) - dy = int(sub_obj.canvas.base_img.size[1] * (sub_obj.actual_pos[3] - sub_obj.actual_pos[1])) - return dx, dy - - def get_actual_pixel_box(self, path: str) -> Union[None, Tuple[int, int, int, int]]: - """ - 获取控件实际像素大小盒子 - 函数执行时间较长 - :param path: 控件路径 - :return: - """ - sub_obj = self - self.save_as((0, 0, 1, 1), True) - control_path = "" - for i, seq in enumerate(path.split(".")): - if seq not in sub_obj.__dict__: - raise KeyError(f"在{control_path}中找不到控件:{seq}") - control_path += f".{seq}" - sub_obj = sub_obj.__dict__[seq] - x1 = int(sub_obj.canvas.base_img.size[0] * sub_obj.actual_pos[0]) - y1 = int(sub_obj.canvas.base_img.size[1] * sub_obj.actual_pos[1]) - x2 = int(sub_obj.canvas.base_img.size[2] * sub_obj.actual_pos[2]) - y2 = int(sub_obj.canvas.base_img.size[3] * sub_obj.actual_pos[3]) - return x1, y1, x2, y2 - - def get_parent_box(self, path: str) -> Union[None, Tuple[float, float, float, float]]: - """ - 获取控件在父节点的大小 - 函数执行时间较长 - - :param path: 控件路径 - :return: - """ - sub_obj = self.get_control_by_path(path) - on_parent_pos = ( - (sub_obj.actual_pos[0] - sub_obj.parent.actual_pos[0]) / (sub_obj.parent.actual_pos[2] - sub_obj.parent.actual_pos[0]), - (sub_obj.actual_pos[1] - sub_obj.parent.actual_pos[1]) / (sub_obj.parent.actual_pos[3] - sub_obj.parent.actual_pos[1]), - (sub_obj.actual_pos[2] - sub_obj.parent.actual_pos[0]) / (sub_obj.parent.actual_pos[2] - sub_obj.parent.actual_pos[0]), - (sub_obj.actual_pos[3] - sub_obj.parent.actual_pos[1]) / (sub_obj.parent.actual_pos[3] - sub_obj.parent.actual_pos[1]) - ) - return on_parent_pos - - def get_control_by_path(self, path: str) -> Union[BasePanel, "Img", "Rectangle", "Text"]: - sub_obj = self - self.save_as((0, 0, 1, 1), True) - control_path = "" - for i, seq in enumerate(path.split(".")): - if seq not in sub_obj.__dict__: - raise KeyError(f"在{control_path}中找不到控件:{seq}") - control_path += f".{seq}" - sub_obj = sub_obj.__dict__[seq] - return sub_obj - - def draw_line(self, path: str, p1: Tuple[float, float], p2: Tuple[float, float], color, width): - """ - 画线 - - :param color: - :param width: - :param path: - :param p1: - :param p2: - :return: - """ - ac_pos = self.get_actual_box(path) - control = self.get_control_by_path(path) - dx = ac_pos[2] - ac_pos[0] - dy = ac_pos[3] - ac_pos[1] - xy_box = int((ac_pos[0] + dx * p1[0]) * control.canvas.base_img.size[0]), int((ac_pos[1] + dy * p1[1]) * control.canvas.base_img.size[1]), int( - (ac_pos[0] + dx * p2[0]) * control.canvas.base_img.size[0]), int((ac_pos[1] + dy * p2[1]) * control.canvas.base_img.size[1]) - self.draw_line_list.append((xy_box, color, width)) - - -class Panel(BasePanel): - def __init__(self, uv_size, box_size, parent_point, point): - super(Panel, self).__init__(uv_size, box_size, parent_point, point) - - -class TextSegment: - def __init__(self, text, **kwargs): - if not isinstance(text, str): - raise TypeError("请输入字符串") - self.text = text - self.color = kwargs.get("color", None) - self.font = kwargs.get("font", None) - - @staticmethod - def text2text_segment_list(text: str): - """ - 暂时没写好 - - :param text: %FFFFFFFF%1123%FFFFFFFF%21323 - :return: - """ - pass - - -class Text(BasePanel): - def __init__(self, uv_size, box_size, parent_point, point, text: Union[str, list], font=default_font, color=(255, 255, 255, 255), vertical=False, - line_feed=False, force_size=False, fill=(0, 0, 0, 0), fillet=0, outline=(0, 0, 0, 0), outline_width=0, rectangle_side=0, font_size=None, dp: int = 5, - anchor: str = "la"): - """ - :param uv_size: - :param box_size: - :param parent_point: - :param point: - :param text: list[TextSegment] | str - :param font: - :param color: - :param vertical: 是否竖直 - :param line_feed: 是否换行 - :param force_size: 强制大小 - :param dp: 字体大小递减精度 - :param anchor : https://www.zhihu.com/question/474216280 - :param fill: 底部填充颜色 - :param fillet: 填充圆角 - :param rectangle_side: 边框宽度 - :param outline: 填充矩形边框颜色 - :param outline_width: 填充矩形边框宽度 - """ - self.actual_pos = None - self.outline_width = outline_width - self.outline = outline - self.fill = fill - self.fillet = fillet - self.font = font - self.text = text - self.color = color - self.force_size = force_size - self.vertical = vertical - self.line_feed = line_feed - self.dp = dp - self.font_size = font_size - self.rectangle_side = rectangle_side - self.anchor = anchor - super(Text, self).__init__(uv_size, box_size, parent_point, point) - - def load(self, only_calculate=False): - """限制区域像素大小""" - if isinstance(self.text, str): - self.text = [ - TextSegment(text=self.text, color=self.color, font=self.font) - ] - all_text = str() - for text in self.text: - all_text += text.text - limited_size = int((self.canvas_box[2] - self.canvas_box[0]) * self.canvas.base_img.size[0]), int((self.canvas_box[3] - self.canvas_box[1]) * self.canvas.base_img.size[1]) - font_size = limited_size[1] if self.font_size is None else self.font_size - image_font = ImageFont.truetype(self.font, font_size) - actual_size = image_font.getsize(all_text) - while (actual_size[0] > limited_size[0] or actual_size[1] > limited_size[1]) and not self.force_size: - font_size -= self.dp - image_font = ImageFont.truetype(self.font, font_size) - actual_size = image_font.getsize(all_text) - draw = ImageDraw.Draw(self.canvas.base_img) - if isinstance(self.parent, Img) or isinstance(self.parent, Text): - self.parent.canvas_box = self.parent.actual_pos - dx0 = self.parent.canvas_box[2] - self.parent.canvas_box[0] - dy0 = self.parent.canvas_box[3] - self.parent.canvas_box[1] - dx1 = actual_size[0] / self.canvas.base_img.size[0] - dy1 = actual_size[1] / self.canvas.base_img.size[1] - start_point = [ - int((self.parent.canvas_box[0] + dx0 * self.parent_point[0] - dx1 * self.point[0]) * self.canvas.base_img.size[0]), - int((self.parent.canvas_box[1] + dy0 * self.parent_point[1] - dy1 * self.point[1]) * self.canvas.base_img.size[1]) - ] - self.actual_pos = ( - start_point[0] / self.canvas.base_img.size[0], - start_point[1] / self.canvas.base_img.size[1], - (start_point[0] + actual_size[0]) / self.canvas.base_img.size[0], - (start_point[1] + actual_size[1]) / self.canvas.base_img.size[1], - ) - self.font_size = font_size - if not only_calculate: - for text_segment in self.text: - if text_segment.color is None: - text_segment.color = self.color - if text_segment.font is None: - text_segment.font = self.font - image_font = ImageFont.truetype(font=text_segment.font, size=font_size) - if self.fill[-1] > 0: - rectangle = Shape.rectangle(size=(actual_size[0] + 2 * self.rectangle_side, actual_size[1] + 2 * self.rectangle_side), fillet=self.fillet, fill=self.fill, - width=self.outline_width, outline=self.outline) - self.canvas.base_img.paste(im=rectangle, box=(start_point[0] - self.rectangle_side, - start_point[1] - self.rectangle_side, - start_point[0] + actual_size[0] + self.rectangle_side, - start_point[1] + actual_size[1] + self.rectangle_side), - mask=rectangle.split()[-1]) - draw.text((start_point[0] - self.rectangle_side, start_point[1] - self.rectangle_side), - text_segment.text, text_segment.color, font=image_font, anchor=self.anchor) - text_width = image_font.getsize(text_segment.text) - start_point[0] += text_width[0] - - -class Img(BasePanel): - def __init__(self, uv_size, box_size, parent_point, point, img: Image.Image, keep_ratio=True): - self.img_base_img = img - self.keep_ratio = keep_ratio - super(Img, self).__init__(uv_size, box_size, parent_point, point) - - def load(self, only_calculate=False): - self.preprocess() - self.img_base_img = self.img_base_img.convert("RGBA") - limited_size = int((self.canvas_box[2] - self.canvas_box[0]) * self.canvas.base_img.size[0]), \ - int((self.canvas_box[3] - self.canvas_box[1]) * self.canvas.base_img.size[1]) - - if self.keep_ratio: - """保持比例""" - actual_ratio = self.img_base_img.size[0] / self.img_base_img.size[1] - limited_ratio = limited_size[0] / limited_size[1] - if actual_ratio >= limited_ratio: - # 图片过长 - self.img_base_img = self.img_base_img.resize( - (int(self.img_base_img.size[0] * limited_size[0] / self.img_base_img.size[0]), - int(self.img_base_img.size[1] * limited_size[0] / self.img_base_img.size[0])) - ) - else: - self.img_base_img = self.img_base_img.resize( - (int(self.img_base_img.size[0] * limited_size[1] / self.img_base_img.size[1]), - int(self.img_base_img.size[1] * limited_size[1] / self.img_base_img.size[1])) - ) - - else: - """不保持比例""" - self.img_base_img = self.img_base_img.resize(limited_size) - - # 占比长度 - if isinstance(self.parent, Img) or isinstance(self.parent, Text): - self.parent.canvas_box = self.parent.actual_pos - - dx0 = self.parent.canvas_box[2] - self.parent.canvas_box[0] - dy0 = self.parent.canvas_box[3] - self.parent.canvas_box[1] - - dx1 = self.img_base_img.size[0] / self.canvas.base_img.size[0] - dy1 = self.img_base_img.size[1] / self.canvas.base_img.size[1] - start_point = ( - int((self.parent.canvas_box[0] + dx0 * self.parent_point[0] - dx1 * self.point[0]) * self.canvas.base_img.size[0]), - int((self.parent.canvas_box[1] + dy0 * self.parent_point[1] - dy1 * self.point[1]) * self.canvas.base_img.size[1]) - ) - alpha = self.img_base_img.split()[3] - self.actual_pos = ( - start_point[0] / self.canvas.base_img.size[0], - start_point[1] / self.canvas.base_img.size[1], - (start_point[0] + self.img_base_img.size[0]) / self.canvas.base_img.size[0], - (start_point[1] + self.img_base_img.size[1]) / self.canvas.base_img.size[1], - ) - if not only_calculate: - self.canvas.base_img.paste(self.img_base_img, start_point, alpha) - - def preprocess(self): - pass - - -class Rectangle(Img): - def __init__(self, uv_size, box_size, parent_point, point, fillet: Union[int, float] = 0, img: Union[Image.Image] = None, keep_ratio=True, - color=default_color, outline_width=0, outline_color=default_color): - """ - 圆角图 - :param uv_size: - :param box_size: - :param parent_point: - :param point: - :param fillet: 圆角半径浮点或整数 - :param img: - :param keep_ratio: - """ - self.fillet = fillet - self.color = color - self.outline_width = outline_width - self.outline_color = outline_color - super(Rectangle, self).__init__(uv_size, box_size, parent_point, point, img, keep_ratio) - - def preprocess(self): - limited_size = (int(self.canvas.base_img.size[0] * (self.canvas_box[2] - self.canvas_box[0])), - int(self.canvas.base_img.size[1] * (self.canvas_box[3] - self.canvas_box[1]))) - if not self.keep_ratio and self.img_base_img is not None and self.img_base_img.size[0] / self.img_base_img.size[1] != limited_size[0] / limited_size[1]: - self.img_base_img = self.img_base_img.resize(limited_size) - self.img_base_img = Shape.rectangle(size=limited_size, fillet=self.fillet, fill=self.color, width=self.outline_width, outline=self.outline_color) - - -class Color: - GREY = (128, 128, 128, 255) - RED = (255, 0, 0, 255) - GREEN = (0, 255, 0, 255) - BLUE = (0, 0, 255, 255) - YELLOW = (255, 255, 0, 255) - PURPLE = (255, 0, 255, 255) - CYAN = (0, 255, 255, 255) - WHITE = (255, 255, 255, 255) - BLACK = (0, 0, 0, 255) - - @staticmethod - def hex2dec(colorHex: str) -> Tuple[int, int, int, int]: - """ - :param colorHex: FFFFFFFF (ARGB)-> (R, G, B, A) - :return: - """ - return int(colorHex[2:4], 16), int(colorHex[4:6], 16), int(colorHex[6:8], 16), int(colorHex[0:2], 16) - - -class Shape: - @staticmethod - def circular(radius: int, fill: tuple, width: int = 0, outline: tuple = Color.BLACK) -> Image.Image: - """ - :param radius: 半径(像素) - :param fill: 填充颜色 - :param width: 轮廓粗细(像素) - :param outline: 轮廓颜色 - :return: 圆形Image对象 - """ - img = Image.new("RGBA", (radius * 2, radius * 2), color=radius) - draw = ImageDraw.Draw(img) - draw.ellipse(xy=(0, 0, radius * 2, radius * 2), fill=fill, outline=outline, width=width) - return img - - @staticmethod - def rectangle(size: Tuple[int, int], fill: tuple, width: int = 0, outline: tuple = Color.BLACK, fillet: int = 0) -> Image.Image: - """ - :param fillet: 圆角半径(像素) - :param size: 长宽(像素) - :param fill: 填充颜色 - :param width: 轮廓粗细(像素) - :param outline: 轮廓颜色 - :return: 矩形Image对象 - """ - img = Image.new("RGBA", size, color=fill) - draw = ImageDraw.Draw(img) - draw.rounded_rectangle(xy=(0, 0, size[0], size[1]), fill=fill, outline=outline, width=width, radius=fillet) - return img - - @staticmethod - def ellipse(size: Tuple[int, int], fill: tuple, outline: int = 0, outline_color: tuple = Color.BLACK) -> Image.Image: - """ - :param size: 长宽(像素) - :param fill: 填充颜色 - :param outline: 轮廓粗细(像素) - :param outline_color: 轮廓颜色 - :return: 椭圆Image对象 - """ - img = Image.new("RGBA", size, color=fill) - draw = ImageDraw.Draw(img) - draw.ellipse(xy=(0, 0, size[0], size[1]), fill=fill, outline=outline_color, width=outline) - return img - - @staticmethod - def polygon(points: List[Tuple[int, int]], fill: tuple, outline: int, outline_color: tuple) -> Image.Image: - """ - :param points: 多边形顶点列表 - :param fill: 填充颜色 - :param outline: 轮廓粗细(像素) - :param outline_color: 轮廓颜色 - :return: 多边形Image对象 - """ - img = Image.new("RGBA", (max(points)[0], max(points)[1]), color=fill) - draw = ImageDraw.Draw(img) - draw.polygon(xy=points, fill=fill, outline=outline_color, width=outline) - return img - - @staticmethod - def line(points: List[Tuple[int, int]], fill: tuple, width: int) -> Image: - """ - :param points: 线段顶点列表 - :param fill: 填充颜色 - :param width: 线段粗细(像素) - :return: 线段Image对象 - """ - img = Image.new("RGBA", (max(points)[0], max(points)[1]), color=fill) - draw = ImageDraw.Draw(img) - draw.line(xy=points, fill=fill, width=width) - return img - - -class Utils: - - @staticmethod - def central_clip_by_ratio(img: Image.Image, size: Tuple, use_cache=True): - """ - :param use_cache: 是否使用缓存,剪切过一次后默认生成缓存 - :param img: - :param size: 仅为比例,满填充裁剪 - :return: - """ - cache_file_path = str() - if use_cache: - filename_without_end = ".".join(os.path.basename(img.fp.name).split(".")[0:-1]) + f"_{size[0]}x{size[1]}" + ".png" - cache_file_path = os.path.join(".cache", filename_without_end) - if os.path.exists(cache_file_path): - nonebot.logger.info("本次使用缓存加载图片,不裁剪") - return Image.open(os.path.join(".cache", filename_without_end)) - img_ratio = img.size[0] / img.size[1] - limited_ratio = size[0] / size[1] - if limited_ratio > img_ratio: - actual_size = ( - img.size[0], - img.size[0] / size[0] * size[1] - ) - box = ( - 0, (img.size[1] - actual_size[1]) // 2, - img.size[0], img.size[1] - (img.size[1] - actual_size[1]) // 2 - ) - else: - actual_size = ( - img.size[1] / size[1] * size[0], - img.size[1], - ) - box = ( - (img.size[0] - actual_size[0]) // 2, 0, - img.size[0] - (img.size[0] - actual_size[0]) // 2, img.size[1] - ) - img = img.crop(box).resize(size) - if use_cache: - img.save(cache_file_path) - return img - - @staticmethod - def circular_clip(img: Image.Image): - """ - 裁剪为alpha圆形 - - :param img: - :return: - """ - length = min(img.size) - alpha_cover = Image.new("RGBA", (length, length), color=(0, 0, 0, 0)) - if img.size[0] > img.size[1]: - box = ( - (img.size[0] - img[1]) // 2, 0, - (img.size[0] - img[1]) // 2 + img.size[1], img.size[1] - ) - else: - box = ( - 0, (img.size[1] - img.size[0]) // 2, - img.size[0], (img.size[1] - img.size[0]) // 2 + img.size[0] - ) - img = img.crop(box).resize((length, length)) - draw = ImageDraw.Draw(alpha_cover) - draw.ellipse(xy=(0, 0, length, length), fill=(255, 255, 255, 255)) - alpha = alpha_cover.split()[-1] - img.putalpha(alpha) - return img - - @staticmethod - def open_img(path) -> Image.Image: - return Image.open(path, "RGBA") diff --git a/src/nonebot_plugins/liteyuki_crt_utils/crt.py b/src/nonebot_plugins/liteyuki_crt_utils/crt.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/nonebot_plugins/liteyuki_crt_utils/crt_matchers.py b/src/nonebot_plugins/liteyuki_crt_utils/crt_matchers.py deleted file mode 100644 index 0a4fa64..0000000 --- a/src/nonebot_plugins/liteyuki_crt_utils/crt_matchers.py +++ /dev/null @@ -1,78 +0,0 @@ -from urllib.parse import quote - -import aiohttp -from nonebot import require - -from src.utils.event import get_user_id -from src.utils.base.language import Language -from src.utils.base.ly_typing import T_MessageEvent -from src.utils.base.resource import get_path -from src.utils.message.html_tool import template2image - -require("nonebot_plugin_alconna") - -from nonebot_plugin_alconna import UniMessage, on_alconna, Alconna, Args, Subcommand, Arparma, Option - -crt_cmd = on_alconna( - Alconna( - "crt", - Subcommand( - "route", - Args["start", str, "沙坪坝"]["end", str, "上新街"], - alias=("r",), - help_text="查询两地之间的地铁路线" - ), - ) -) - - -@crt_cmd.assign("route") -async def _(result: Arparma, event: T_MessageEvent): - # 获取语言 - ulang = Language(get_user_id(event)) - - # 获取参数 - # 你也别问我为什么要quote两次,问就是CRT官网的锅,只有这样才可以运行 - start = quote(quote(result.other_args.get("start"))) - end = quote(quote(result.other_args.get("end"))) - - # 判断参数语言 - query_lang_code = "" - if start.isalpha() and end.isalpha(): - query_lang_code = "Eng" - - # 构造请求 URL - url = f"https://www.cqmetro.cn/Front/html/TakeLine!queryYs{query_lang_code}TakeLine.action?entity.startStaName={start}&entity.endStaName={end}" - - # 请求数据 - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - result = await resp.json() - - # 检查结果/无则终止 - if not result.get("result"): - await crt_cmd.send(ulang.get("crt.no_result")) - return - - # 模板传参定义 - templates = { - "data" : { - "result": result["result"], - }, - "localization": ulang.get_many( - "crt.station", - "crt.hour", - "crt.minute", - ) - - } - - # 生成图片 - image = await template2image( - template=get_path("templates/crt_route.html"), - templates=templates, - debug=True - ) - - # 发送图片 - await crt_cmd.send(UniMessage.image(raw=image)) diff --git a/src/nonebot_plugins/liteyuki_crt_utils/rt_guide.py b/src/nonebot_plugins/liteyuki_crt_utils/rt_guide.py deleted file mode 100644 index 4167cbf..0000000 --- a/src/nonebot_plugins/liteyuki_crt_utils/rt_guide.py +++ /dev/null @@ -1,419 +0,0 @@ -import json -from typing import List, Any - -from PIL import Image -from arclet.alconna import Alconna -from nb_cli import run_sync -from nonebot import on_command -from nonebot_plugin_alconna import on_alconna, Alconna, Subcommand, Args, MultiVar, Arparma, UniMessage -from pydantic import BaseModel - -from .canvas import * -from ...utils.base.resource import get_path - -resolution = 256 - - -class Entrance(BaseModel): - identifier: str - size: tuple[int, int] - dest: List[str] - - -class Station(BaseModel): - identifier: str - chineseName: str - englishName: str - position: tuple[int, int] - - -class Line(BaseModel): - identifier: str - chineseName: str - englishName: str - color: Any - stations: List["Station"] - - -font_light = get_path("templates/fonts/MiSans/MiSans-Light.woff2") -font_bold = get_path("templates/fonts/MiSans/MiSans-Bold.woff2") - -@run_sync -def generate_entrance_sign(name: str, aliases: List[str], lineInfo: List[Line], entranceIdentifier: str, ratio: tuple[int | float, int | float], - reso: int = resolution): - """ - Generates an entrance sign for the ride. - """ - width, height = ratio[0] * reso, ratio[1] * reso - baseCanvas = Canvas(Image.new("RGBA", (width, height), Color.WHITE)) - # 加黑色图框 - baseCanvas.outline = Img( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0, 0), - point=(0, 0), - img=Shape.rectangle( - size=(width, height), - fillet=0, - fill=(0, 0, 0, 0), - width=15, - outline=Color.BLACK - ) - ) - - baseCanvas.contentPanel = Panel( - uv_size=(width, height), - box_size=(width - 28, height - 28), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - ) - - linePanelHeight = 0.7 * ratio[1] - linePanelWidth = linePanelHeight * 1.3 - - # 画线路面板部分 - - for i, line in enumerate(lineInfo): - linePanel = baseCanvas.contentPanel.__dict__[f"Line_{i}_Panel"] = Panel( - uv_size=ratio, - box_size=(linePanelWidth, linePanelHeight), - parent_point=(i * linePanelWidth / ratio[0], 1), - point=(0, 1), - ) - - linePanel.colorCube = Img( - uv_size=(1, 1), - box_size=(0.15, 1), - parent_point=(0.125, 1), - point=(0, 1), - img=Shape.rectangle( - size=(100, 100), - fillet=0, - fill=line.color, - ), - keep_ratio=False - ) - - textPanel = linePanel.TextPanel = Panel( - uv_size=(1, 1), - box_size=(0.625, 1), - parent_point=(1, 1), - point=(1, 1) - ) - - # 中文线路名 - textPanel.namePanel = Panel( - uv_size=(1, 1), - box_size=(1, 2 / 3), - parent_point=(0, 0), - point=(0, 0), - ) - nameSize = baseCanvas.get_actual_pixel_size("contentPanel.Line_{}_Panel.TextPanel.namePanel".format(i)) - textPanel.namePanel.text = Text( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - text=line.chineseName, - color=Color.BLACK, - font_size=int(nameSize[1] * 0.5), - force_size=True, - font=font_bold - - ) - - # 英文线路名 - textPanel.englishNamePanel = Panel( - uv_size=(1, 1), - box_size=(1, 1 / 3), - parent_point=(0, 1), - point=(0, 1), - ) - englishNameSize = baseCanvas.get_actual_pixel_size("contentPanel.Line_{}_Panel.TextPanel.englishNamePanel".format(i)) - textPanel.englishNamePanel.text = Text( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - text=line.englishName, - color=Color.BLACK, - font_size=int(englishNameSize[1] * 0.6), - force_size=True, - font=font_light - ) - - # 画名称部分 - namePanel = baseCanvas.contentPanel.namePanel = Panel( - uv_size=(1, 1), - box_size=(1, 0.4), - parent_point=(0.5, 0), - point=(0.5, 0), - ) - - namePanel.text = Text( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - text=name, - color=Color.BLACK, - font_size=int(height * 0.3), - force_size=True, - font=font_bold - ) - - aliasesPanel = baseCanvas.contentPanel.aliasesPanel = Panel( - uv_size=(1, 1), - box_size=(1, 0.5), - parent_point=(0.5, 1), - point=(0.5, 1), - - ) - for j, alias in enumerate(aliases): - aliasesPanel.__dict__[alias] = Text( - uv_size=(1, 1), - box_size=(0.35, 0.5), - parent_point=(0.5, 0.5 * j), - point=(0.5, 0), - text=alias, - color=Color.BLACK, - font_size=int(height * 0.15), - font=font_light - ) - - # 画入口标识 - entrancePanel = baseCanvas.contentPanel.entrancePanel = Panel( - uv_size=(1, 1), - box_size=(0.2, 1), - parent_point=(1, 0.5), - point=(1, 0.5), - ) - # 中文文本 - entrancePanel.namePanel = Panel( - uv_size=(1, 1), - box_size=(1, 0.5), - parent_point=(1, 0), - point=(1, 0), - ) - entrancePanel.namePanel.text = Text( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0, 0.5), - point=(0, 0.5), - text=f"{entranceIdentifier}出入口", - color=Color.BLACK, - font_size=int(height * 0.2), - force_size=True, - font=font_bold - ) - # 英文文本 - entrancePanel.englishNamePanel = Panel( - uv_size=(1, 1), - box_size=(1, 0.5), - parent_point=(1, 1), - point=(1, 1), - ) - entrancePanel.englishNamePanel.text = Text( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0, 0.5), - point=(0, 0.5), - text=f"Entrance {entranceIdentifier}", - color=Color.BLACK, - font_size=int(height * 0.15), - force_size=True, - font=font_light - ) - - return baseCanvas.base_img.tobytes() - - -crt_alc = on_alconna( - Alconna( - "crt", - Subcommand( - "entrance", - Args["name", str]["lines", str, ""]["entrance", int, 1], # /crt entrance 璧山&Bishan 1号线&Line1&#ff0000,27号线&Line1&#ff0000 1A - ) - ) -) - - -@crt_alc.assign("entrance") -async def _(result: Arparma): - args = result.subcommands.get("entrance").args - name = args["name"] - lines = args["lines"] - entrance = args["entrance"] - line_info = [] - for line in lines.split(","): - line_args = line.split("&") - line_info.append(Line( - identifier=1, - chineseName=line_args[0], - englishName=line_args[1], - color=line_args[2], - stations=[] - )) - img_bytes = await generate_entrance_sign( - name=name, - aliases=name.split("&"), - lineInfo=line_info, - entranceIdentifier=entrance, - ratio=(8, 1), - reso=256, - ) - await crt_alc.finish( - UniMessage.image(raw=img_bytes) - ) - - -def generate_platform_line_pic(line: Line, station: Station, ratio=None, reso: int = resolution): - """ - 生成站台线路图 - :param line: 线路对象 - :param station: 本站点对象 - :param ratio: 比例 - :param reso: 分辨率,1:reso - :return: 两个方向的站牌 - """ - if ratio is None: - ratio = [4, 1] - width, height = ratio[0] * reso, ratio[1] * reso - baseCanvas = Canvas(Image.new("RGBA", (width, height), Color.YELLOW)) - # 加黑色图框 - baseCanvas.linePanel = Panel( - uv_size=(1, 1), - box_size=(0.8, 0.15), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - ) - - # 直线块 - baseCanvas.linePanel.recLine = Img( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - img=Shape.rectangle( - size=(10, 10), - fill=line.color, - ), - keep_ratio=False - ) - # 灰色直线块 - baseCanvas.linePanel.recLineGrey = Img( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - img=Shape.rectangle( - size=(10, 10), - fill=Color.GREY, - ), - keep_ratio=False - ) - # 生成各站圆点 - outline_width = 40 - circleForward = Shape.circular( - radius=200, - fill=Color.WHITE, - width=outline_width, - outline=line.color, - ) - - circleThisPanel = Canvas(Image.new("RGBA", (200, 200), (0, 0, 0, 0))) - circleThisPanel.circleOuter = Img( - uv_size=(1, 1), - box_size=(1, 1), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - img=Shape.circular( - radius=200, - fill=Color.WHITE, - width=outline_width, - outline=line.color, - ), - ) - circleThisPanel.circleOuter.circleInner = Img( - uv_size=(1, 1), - box_size=(0.7, 0.7), - parent_point=(0.5, 0.5), - point=(0.5, 0.5), - img=Shape.circular( - radius=200, - fill=line.color, - width=0, - outline=line.color, - ), - ) - - circleThisPanel.export("a.png", alpha=True) - circleThis = circleThisPanel.base_img - - circlePassed = Shape.circular( - radius=200, - fill=Color.WHITE, - width=outline_width, - outline=Color.GREY, - ) - - arrival = False - distance = 1 / (len(line.stations) - 1) - for i, sta in enumerate(line.stations): - box_size = (1.618, 1.618) - if sta.identifier == station.identifier: - arrival = True - baseCanvas.linePanel.recLine.__dict__["station_{}".format(sta.identifier)] = Img( - uv_size=(1, 1), - box_size=(1.8, 1.8), - parent_point=(distance * i, 0.5), - point=(0.5, 0.5), - img=circleThis, - keep_ratio=True - ) - continue - if arrival: - # 后方站绘制 - baseCanvas.linePanel.recLine.__dict__["station_{}".format(sta.identifier)] = Img( - uv_size=(1, 1), - box_size=box_size, - parent_point=(distance * i, 0.5), - point=(0.5, 0.5), - img=circleForward, - keep_ratio=True - ) - else: - # 前方站绘制 - baseCanvas.linePanel.recLine.__dict__["station_{}".format(sta.identifier)] = Img( - uv_size=(1, 1), - box_size=box_size, - parent_point=(distance * i, 0.5), - point=(0.5, 0.5), - img=circlePassed, - keep_ratio=True - ) - return baseCanvas - - -def generate_platform_sign(name: str, aliases: List[str], lineInfo: List[Line], entranceIdentifier: str, ratio: tuple[int | float, int | float], - reso: int = resolution - ): - pass - -# def main(): -# generate_entrance_sign( -# "璧山", -# aliases=["Bishan"], -# lineInfo=[ -# -# Line(identifier="2", chineseName="1号线", englishName="Line 1", color=Color.RED, stations=[]), -# Line(identifier="3", chineseName="27号线", englishName="Line 27", color="#685bc7", stations=[]), -# Line(identifier="1", chineseName="璧铜线", englishName="BT Line", color="#685BC7", stations=[]), -# ], -# entranceIdentifier="1", -# ratio=(8, 1) -# ) -# -# -# main() diff --git a/src/nonebot_plugins/liteyuki_minigame/__init__.py b/src/nonebot_plugins/liteyuki_minigame/__init__.py deleted file mode 100644 index 3dd85d9..0000000 --- a/src/nonebot_plugins/liteyuki_minigame/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from nonebot.plugin import PluginMetadata -from .minesweeper import * - -__plugin_meta__ = PluginMetadata( - name="轻雪小游戏", - description="内置了一些小游戏", - usage="", - type="application", - homepage="https://github.com/snowykami/LiteyukiBot", - extra={ - "liteyuki": True, - "toggleable" : True, - "default_enable" : True, - } -) diff --git a/src/nonebot_plugins/liteyuki_minigame/game.py b/src/nonebot_plugins/liteyuki_minigame/game.py deleted file mode 100644 index 8ed838c..0000000 --- a/src/nonebot_plugins/liteyuki_minigame/game.py +++ /dev/null @@ -1,168 +0,0 @@ -import random -from pydantic import BaseModel -from src.utils.message.message import MarkdownMessage as md - -class Dot(BaseModel): - row: int - col: int - mask: bool = True - value: int = 0 - flagged: bool = False - - -class Minesweeper: - # 0-8: number of mines around, 9: mine, -1: undefined - NUMS = "⓪①②③④⑤⑥⑦⑧🅑⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" - MASK = "🅜" - FLAG = "🅕" - MINE = "🅑" - - def __init__(self, rows, cols, num_mines, session_type, session_id): - assert rows > 0 and cols > 0 and 0 < num_mines < rows * cols - self.session_type = session_type - self.session_id = session_id - self.rows = rows - self.cols = cols - self.num_mines = num_mines - self.board: list[list[Dot]] = [[Dot(row=i, col=j) for j in range(cols)] for i in range(rows)] - self.is_first = True - - def reveal(self, row, col) -> bool: - """ - 展开 - Args: - row: - col: - - Returns: - 游戏是否继续 - - """ - - if self.is_first: - # 第一次展开,生成地雷 - self.generate_board(self.board[row][col]) - self.is_first = False - - if self.board[row][col].value == 9: - self.board[row][col].mask = False - return False - - if not self.board[row][col].mask: - return True - - self.board[row][col].mask = False - - if self.board[row][col].value == 0: - self.reveal_neighbors(row, col) - return True - - def is_win(self) -> bool: - """ - 是否胜利 - Returns: - """ - for row in range(self.rows): - for col in range(self.cols): - if self.board[row][col].mask and self.board[row][col].value != 9: - return False - return True - - def generate_board(self, first_dot: Dot): - """ - 避开第一个点,生成地雷 - Args: - first_dot: 第一个点 - - Returns: - - """ - generate_count = 0 - while generate_count < self.num_mines: - row = random.randint(0, self.rows - 1) - col = random.randint(0, self.cols - 1) - if self.board[row][col].value == 9 or (row, col) == (first_dot.row, first_dot.col): - continue - self.board[row][col] = Dot(row=row, col=col, mask=True, value=9) - generate_count += 1 - - for row in range(self.rows): - for col in range(self.cols): - if self.board[row][col].value != 9: - self.board[row][col].value = self.count_adjacent_mines(row, col) - - def count_adjacent_mines(self, row, col): - """ - 计算周围地雷数量 - Args: - row: - col: - - Returns: - - """ - count = 0 - for r in range(max(0, row - 1), min(self.rows, row + 2)): - for c in range(max(0, col - 1), min(self.cols, col + 2)): - if self.board[r][c].value == 9: - count += 1 - return count - - def reveal_neighbors(self, row, col): - """ - 递归展开,使用深度优先搜索 - Args: - row: - col: - - Returns: - - """ - for r in range(max(0, row - 1), min(self.rows, row + 2)): - for c in range(max(0, col - 1), min(self.cols, col + 2)): - if self.board[r][c].mask: - self.board[r][c].mask = False - if self.board[r][c].value == 0: - self.reveal_neighbors(r, c) - - def mark(self, row, col) -> bool: - """ - 标记 - Args: - row: - col: - Returns: - 是否标记成功,如果已经展开则无法标记 - """ - if self.board[row][col].mask: - self.board[row][col].flagged = not self.board[row][col].flagged - return self.board[row][col].flagged - - def board_markdown(self) -> str: - """ - 打印地雷板 - Returns: - """ - dis = " " - start = "> " if self.cols >= 10 else "" - text = start + self.NUMS[0] + dis*2 - # 横向两个雷之间的间隔字符 - # 生成横向索引 - for i in range(self.cols): - text += f"{self.NUMS[i]}" + dis - text += "\n\n" - for i, row in enumerate(self.board): - text += start + f"{self.NUMS[i]}" + dis*2 - for dot in row: - if dot.mask and not dot.flagged: - text += md.btn_cmd(self.MASK, f"minesweeper reveal {dot.row} {dot.col}") - elif dot.flagged: - text += md.btn_cmd(self.FLAG, f"minesweeper mark {dot.row} {dot.col}") - else: - text += self.NUMS[dot.value] - text += dis - text += "\n" - btn_mark = md.btn_cmd("标记", f"minesweeper mark ", enter=False) - btn_end = md.btn_cmd("结束", "minesweeper end", enter=True) - text += f" {btn_mark} {btn_end}" - return text diff --git a/src/nonebot_plugins/liteyuki_minigame/minesweeper.py b/src/nonebot_plugins/liteyuki_minigame/minesweeper.py deleted file mode 100644 index 715bb7a..0000000 --- a/src/nonebot_plugins/liteyuki_minigame/minesweeper.py +++ /dev/null @@ -1,103 +0,0 @@ -from nonebot import require - -from src.utils.base.ly_typing import T_Bot, T_MessageEvent -from src.utils.message.message import MarkdownMessage as md - -require("nonebot_plugin_alconna") -from .game import Minesweeper - -from nonebot_plugin_alconna import Alconna, on_alconna, Subcommand, Args, Arparma - -minesweeper = on_alconna( - aliases={"扫雷"}, - command=Alconna( - "minesweeper", - Subcommand( - "start", - Args["row", int, 8]["col", int, 8]["mines", int, 10], - alias=["开始"], - - ), - Subcommand( - "end", - alias=["结束"] - ), - Subcommand( - "reveal", - Args["row", int]["col", int], - alias=["展开"] - - ), - Subcommand( - "mark", - Args["row", int]["col", int], - alias=["标记"] - ), - ), -) - -minesweeper_cache: list[Minesweeper] = [] - - -def get_minesweeper_cache(event: T_MessageEvent) -> Minesweeper | None: - for i in minesweeper_cache: - if i.session_type == event.message_type: - if i.session_id == event.user_id or i.session_id == event.group_id: - return i - return None - - -@minesweeper.handle() -async def _(event: T_MessageEvent, result: Arparma, bot: T_Bot): - game = get_minesweeper_cache(event) - if result.subcommands.get("start"): - if game: - await minesweeper.finish("当前会话不能同时进行多个扫雷游戏") - else: - try: - new_game = Minesweeper( - rows=result.subcommands["start"].args["row"], - cols=result.subcommands["start"].args["col"], - num_mines=result.subcommands["start"].args["mines"], - session_type=event.message_type, - session_id=event.user_id if event.message_type == "private" else event.group_id, - ) - minesweeper_cache.append(new_game) - await minesweeper.send("游戏开始") - await md.send_md(new_game.board_markdown(), bot, event=event) - except AssertionError: - await minesweeper.finish("参数错误") - elif result.subcommands.get("end"): - if game: - minesweeper_cache.remove(game) - await minesweeper.finish("游戏结束") - else: - await minesweeper.finish("当前没有扫雷游戏") - elif result.subcommands.get("reveal"): - if not game: - await minesweeper.finish("当前没有扫雷游戏") - else: - row = result.subcommands["reveal"].args["row"] - col = result.subcommands["reveal"].args["col"] - if not (0 <= row < game.rows and 0 <= col < game.cols): - await minesweeper.finish("参数错误") - if not game.reveal(row, col): - minesweeper_cache.remove(game) - await md.send_md(game.board_markdown(), bot, event=event) - await minesweeper.finish("游戏结束") - await md.send_md(game.board_markdown(), bot, event=event) - if game.is_win(): - minesweeper_cache.remove(game) - await minesweeper.finish("游戏胜利") - elif result.subcommands.get("mark"): - if not game: - await minesweeper.finish("当前没有扫雷游戏") - else: - row = result.subcommands["mark"].args["row"] - col = result.subcommands["mark"].args["col"] - if not (0 <= row < game.rows and 0 <= col < game.cols): - await minesweeper.finish("参数错误") - game.board[row][col].flagged = not game.board[row][col].flagged - await md.send_md(game.board_markdown(), bot, event=event) - else: - await minesweeper.finish("参数错误") diff --git a/src/nonebot_plugins/liteyuki_pacman/npm.py b/src/nonebot_plugins/liteyuki_pacman/npm.py index 97cb3d3..35f54a1 100644 --- a/src/nonebot_plugins/liteyuki_pacman/npm.py +++ b/src/nonebot_plugins/liteyuki_pacman/npm.py @@ -14,17 +14,21 @@ from nonebot.permission import SUPERUSER from nonebot.plugin import Plugin, PluginMetadata from nonebot.utils import run_sync + from src.utils.base.data_manager import InstalledPlugin from src.utils.base.language import get_user_lang from src.utils.base.ly_typing import T_Bot -from src.utils.message.message import MarkdownMessage as md -from src.utils.message.markdown import MarkdownComponent as mdc, compile_md, escape_md from src.utils.base.permission import GROUP_ADMIN, GROUP_OWNER from src.utils.message.tools import clamp +from src.utils.message.message import MarkdownMessage as md +from src.utils.message.markdown import MarkdownComponent as mdc, compile_md, escape_md +from src.utils.message.html_tool import md_to_pic from .common import * + require("nonebot_plugin_alconna") from nonebot_plugin_alconna import ( + UniMessage, on_alconna, Alconna, Args, @@ -147,7 +151,7 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): session_id = event.group_id new_event = event else: - raise FinishedException(ulang.get("Permission Denied")) + raise FinishedException(ulang.get("liteyuki.permission_denied")) session_enable = get_plugin_session_enable( new_event, plugin_name @@ -292,7 +296,8 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): reply += f"\n{ulang.get('npm.too_many_results', HIDE_NUM=len(rs) - max_show)}" else: reply = ulang.get("npm.search_no_result") - await md.send_md(reply, bot, event=event) + img_bytes = await md_to_pic(reply) + await UniMessage.send(UniMessage.image(raw=img_bytes)) elif sc.get("install") and perm_s: plugin_name: str = result.subcommands["install"].args.get("plugin_name") @@ -320,7 +325,7 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): info = md.escape( ulang.get("npm.install_success", NAME=store_plugin.name) ) # markdown转义 - await md.send_md(f"{info}\n\n" f"```\n{log}\n```", bot, event=event) + await npm.send(f"{info}\n\n" + f"\n{log}\n") else: await npm.finish( ulang.get( @@ -331,12 +336,12 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): info = ulang.get( "npm.load_failed", NAME=plugin_name, HOMEPAGE=homepage_btn ).replace("_", r"\\_") - await md.send_md(f"{info}\n\n" f"```\n{log}\n```\n", bot, event=event) + await npm.finish(f"{info}\n\n" f"```\n{log}\n```\n") else: info = ulang.get( "npm.install_failed", NAME=plugin_name, HOMEPAGE=homepage_btn ).replace("_", r"\\_") - await md.send_md(f"{info}\n\n" f"```\n{log}\n```", bot, event=event) + await npm.send(f"{info}\n\n" f"```\n{log}\n```") elif sc.get("uninstall") and perm_s: plugin_name: str = result.subcommands["uninstall"].args.get("plugin_name") # type: ignore @@ -464,7 +469,8 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): else ulang.get("npm.next_page") ) reply += f"\n{btn_prev} {page}/{total} {btn_next}" - await md.send_md(reply, bot, event=event) + img_bytes = await md_to_pic(reply) + await UniMessage.send(UniMessage.image(raw=img_bytes)) else: if await SUPERUSER(bot, event): @@ -517,7 +523,8 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): f"\n\n>page为页数,num为每页显示数量" f"\n\n>*{md.escape('npm list [page] [num]')}*" ) - await md.send_md(reply, bot, event=event) + img_bytes = await md_to_pic(reply) + await UniMessage.send(UniMessage.image(raw=img_bytes)) else: btn_list = md.btn_cmd( @@ -539,7 +546,8 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): f"\n\n>page为页数,num为每页显示数量" f"\n\n>*{md.escape('npm list [page] [num]')}*" ) - await md.send_md(reply, bot, event=event) + img_bytes = await md_to_pic(reply) + await UniMessage.send(UniMessage.image(raw=img_bytes)) @on_alconna( @@ -554,7 +562,7 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot, npm: Matcher): Subcommand( disable, Args["group_id", str, None], - alias=["d", "停用", "禁用"], + alias=["d", "停用"], ), ), permission=SUPERUSER | GROUP_OWNER | GROUP_ADMIN, @@ -679,7 +687,7 @@ async def _(result: Arparma, matcher: Matcher, event: T_MessageEvent, bot: T_Bot else mdc.paragraph(ulang.get("npm.homepage")) ), ] - await md.send_md(compile_md(reply), bot, event=event) + await matcher.finish(compile_md(reply)) else: await matcher.finish(ulang.get("npm.plugin_not_found", NAME=plugin_name)) else: diff --git a/src/nonebot_plugins/liteyuki_pacman/rpm.py b/src/nonebot_plugins/liteyuki_pacman/rpm.py index 732372e..9f8def1 100644 --- a/src/nonebot_plugins/liteyuki_pacman/rpm.py +++ b/src/nonebot_plugins/liteyuki_pacman/rpm.py @@ -198,7 +198,7 @@ async def _(bot: T_Bot, event: T_MessageEvent, result: Arparma, matcher: Matcher else: pass if send_as_md: - await md.send_md(reply, bot, event=event) + await matcher.send(reply) else: if reply: await matcher.finish(reply) diff --git a/src/nonebot_plugins/liteyuki_satori_user_info/auto_update.py b/src/nonebot_plugins/liteyuki_satori_user_info/auto_update.py index 64fc573..9533b05 100644 --- a/src/nonebot_plugins/liteyuki_satori_user_info/auto_update.py +++ b/src/nonebot_plugins/liteyuki_satori_user_info/auto_update.py @@ -2,7 +2,6 @@ import nonebot from nonebot.message import event_preprocessor -# from nonebot_plugin_alconna.typings import Event from src.utils.base.ly_typing import T_MessageEvent from src.utils import satori_utils from nonebot.adapters import satori diff --git a/src/nonebot_plugins/liteyuki_sign_status.py b/src/nonebot_plugins/liteyuki_sign_status.py deleted file mode 100644 index 5f3ad44..0000000 --- a/src/nonebot_plugins/liteyuki_sign_status.py +++ /dev/null @@ -1,163 +0,0 @@ -import datetime -import time - -import aiohttp -from nonebot import require -from nonebot.plugin import PluginMetadata - -from src.utils.base.config import get_config -from src.utils.base.data import Database, LiteModel -from src.utils.base.resource import get_path -from src.utils.message.html_tool import template2image - -require("nonebot_plugin_alconna") -require("nonebot_plugin_apscheduler") -from nonebot_plugin_apscheduler import scheduler -from nonebot_plugin_alconna import Alconna, AlconnaResult, CommandResult, Subcommand, UniMessage, on_alconna, Args - -__author__ = "snowykami" -__plugin_meta__ = PluginMetadata( - name="签名服务器状态", - description="适用于ntqq的签名状态查看", - usage=( - "sign count 查看当前签名数\n" - "sign data 查看签名数变化\n" - "sign chart [limit] 查看签名数变化图表\n" - ), - type="application", - homepage="https://github.com/snowykami/LiteyukiBot", - extra={ - "liteyuki" : True, - "toggleable" : True, - "default_enable": True, - } -) - -SIGN_COUNT_URLS: dict[str, str] = get_config("sign_count_urls", None) -SIGN_COUNT_DURATION = get_config("sign_count_duration", 10) - - -class SignCount(LiteModel): - TABLE_NAME: str = "sign_count" - time: float = 0.0 - count: int = 0 - sid: str = "" - - -sign_db = Database("data/liteyuki/ntqq_sign.ldb") -sign_db.auto_migrate(SignCount()) - -sign_status = on_alconna(Alconna( - "sign", - Subcommand( - "chart", - Args["limit", int, 10000] - ), - Subcommand( - "count" - ), - Subcommand( - "data" - ) -)) - -cache_img: bytes = None - - -@sign_status.assign("count") -async def _(): - reply = "Current sign count:" - for name, count in (await get_now_sign()).items(): - reply += f"\n{name}: {count[1]}" - await sign_status.send(reply) - - -@sign_status.assign("data") -async def _(): - query_stamp = [1, 5, 10, 15] - - reply = "QPS from last " + ", ".join([str(i) for i in query_stamp]) + "mins" - for name, url in SIGN_COUNT_URLS.items(): - count_data = [] - for stamp in query_stamp: - count_rows = sign_db.where_all(SignCount(), "sid = ? and time > ?", url, time.time() - 60 * stamp) - if len(count_rows) < 2: - count_data.append(-1) - else: - count_data.append((count_rows[-1].count - count_rows[0].count)/(stamp*60)) - reply += f"\n{name}: " + ", ".join([f"{i:.1f}" for i in count_data]) - await sign_status.send(reply) - - -@sign_status.assign("chart") -async def _(arp: CommandResult = AlconnaResult()): - limit = arp.result.subcommands.get("chart").args.get("limit") - if limit == 10000: - if cache_img: - await sign_status.send(UniMessage.image(raw=cache_img)) - return - img = await generate_chart(limit) - await sign_status.send(UniMessage.image(raw=img)) - - -@scheduler.scheduled_job("interval", seconds=SIGN_COUNT_DURATION, next_run_time=datetime.datetime.now()) -async def update_sign_count(): - global cache_img - if not SIGN_COUNT_URLS: - return - data = await get_now_sign() - for name, count in data.items(): - await save_sign_count(count[0], count[1], SIGN_COUNT_URLS[name]) - - cache_img = await generate_chart(10000) - - -async def get_now_sign() -> dict[str, tuple[float, int]]: - """ - Get the sign count and the time of the latest sign - Returns: - tuple[float, int] | None: (time, count) - """ - data = {} - now = time.time() - async with aiohttp.ClientSession() as client: - for name, url in SIGN_COUNT_URLS.items(): - async with client.get(url) as resp: - count = (await resp.json())["count"] - data[name] = (now, count) - return data - - -async def save_sign_count(timestamp: float, count: int, sid: str): - """ - Save the sign count to the database - Args: - sid: the sign id, use url as the id - count: - timestamp (float): the time of the sign count (int): the count of the sign - """ - sign_db.save(SignCount(time=timestamp, count=count, sid=sid)) - - -async def generate_chart(limit): - data = [] - for name, url in SIGN_COUNT_URLS.items(): - count_rows = sign_db.where_all(SignCount(), "sid = ? ORDER BY id DESC LIMIT ?", url, limit) - count_rows.reverse() - data.append( - { - "name" : name, - # "data": [[row.time, row.count] for row in count_rows] - "times" : [row.time for row in count_rows], - "counts": [row.count for row in count_rows] - } - ) - - img = await template2image( - template=get_path("templates/sign_status.html"), - templates={ - "data": data - }, - ) - - return img diff --git a/src/nonebot_plugins/liteyuki_statistics/data_source.py b/src/nonebot_plugins/liteyuki_statistics/data_source.py index 64b67cf..e39d7bc 100644 --- a/src/nonebot_plugins/liteyuki_statistics/data_source.py +++ b/src/nonebot_plugins/liteyuki_statistics/data_source.py @@ -61,6 +61,8 @@ async def get_stat_msg_image( condition_args.append(user_id) msg_rows = msg_db.where_all(MessageEventModel(), condition, *condition_args) + if not msg_rows: + msg_rows = [] timestamps = [] msg_count = [] msg_rows.sort(key=lambda x: x.time) @@ -157,8 +159,8 @@ async def get_stat_rank_image( templates = { "data": { "name": ulang.get("stat.rank") - + f" 类别:{rank_type}" - + f" 制约:{limit}", + + f" Type {rank_type}" + + f" Limit {limit}", "ranking": ranking, } } diff --git a/src/nonebot_plugins/liteyuki_statistics/stat_matchers.py b/src/nonebot_plugins/liteyuki_statistics/stat_matchers.py index 2c322f1..d631c4f 100644 --- a/src/nonebot_plugins/liteyuki_statistics/stat_matchers.py +++ b/src/nonebot_plugins/liteyuki_statistics/stat_matchers.py @@ -96,8 +96,10 @@ async def _(result: Arparma, event: T_MessageEvent, bot: Bot): bot_id = result.other_args.get("bot_id") user_id = result.other_args.get("user_id") - if group_id in ["current", "c"]: + if group_id in ["current", "c"] and hasattr(event, "group_id"): group_id = str(event_utils.get_group_id(event)) + else: + group_id = "all" if group_id in ["all", "a"]: group_id = "all" diff --git a/src/nonebot_plugins/liteyuki_status/api.py b/src/nonebot_plugins/liteyuki_status/api.py index 4145dcf..c432dd9 100644 --- a/src/nonebot_plugins/liteyuki_status/api.py +++ b/src/nonebot_plugins/liteyuki_status/api.py @@ -7,7 +7,8 @@ from cpuinfo import cpuinfo from nonebot import require from nonebot.adapters import satori -from src.utils import __NAME__, __VERSION__ +from src.utils import __NAME__ +from liteyuki import __version__ from src.utils.base.config import get_config from src.utils.base.data_manager import TempConfig, common_db from src.utils.base.language import Language @@ -227,11 +228,19 @@ async def get_hardware_data() -> dict: pass swap = psutil.swap_memory() cpu_brand_raw = cpuinfo.get_cpu_info().get("brand_raw", "未知处理器") - if "AMD" in cpu_brand_raw: + if "amd" in cpu_brand_raw.lower(): brand = "AMD" - elif "Intel" in cpu_brand_raw: + elif "intel" in cpu_brand_raw: brand = "英特尔" - elif "Nvidia" in cpu_brand_raw: + elif "apple" in cpu_brand_raw.lower(): + brand = "苹果" + elif "qualcomm" in cpu_brand_raw.lower(): + brand = "高通" + elif "mediatek" in cpu_brand_raw.lower(): + brand = "联发科" + elif "samsung" in cpu_brand_raw.lower(): + brand = "三星" + elif "nvidia" in cpu_brand_raw.lower(): brand = "英伟达" else: brand = "未知处理器" @@ -262,7 +271,9 @@ async def get_hardware_data() -> dict: for disk in psutil.disk_partitions(all=True): try: disk_usage = psutil.disk_usage(disk.mountpoint) - if disk_usage.total == 0: + if disk_usage.total == 0 or disk.mountpoint.startswith( + ("/var", "/boot", "/run", "/proc", "/sys", "/dev", "/tmp", "/snap") + ): continue # 虚拟磁盘 result["disk"].append( { @@ -283,7 +294,7 @@ async def get_liteyuki_data() -> dict: temp_data: TempConfig = common_db.where_one(TempConfig(), default=TempConfig()) result = { "name": list(get_config("nickname", [__NAME__]))[0], - "version": f"{__VERSION__}{'-' + commit_hash[:7] if (commit_hash and len(commit_hash) > 8) else ''}", + "version": f"{__version__}{'-' + commit_hash[:7] if (commit_hash and len(commit_hash) > 8) else ''}", "plugins": len(nonebot.get_loaded_plugins()), "resources": len(get_loaded_resource_packs()), "nonebot": f"{nonebot.__version__}", diff --git a/src/nonebot_plugins/liteyuki_uniblacklist/api.py b/src/nonebot_plugins/liteyuki_uniblacklist/api.py index 52ca276..0e63184 100644 --- a/src/nonebot_plugins/liteyuki_uniblacklist/api.py +++ b/src/nonebot_plugins/liteyuki_uniblacklist/api.py @@ -1,7 +1,6 @@ import datetime import aiohttp -import httpx import nonebot from nonebot import require from nonebot.exception import IgnoredException diff --git a/src/nonebot_plugins/liteyuki_user/profile_manager.py b/src/nonebot_plugins/liteyuki_user/profile_manager.py index bdde211..b14cced 100644 --- a/src/nonebot_plugins/liteyuki_user/profile_manager.py +++ b/src/nonebot_plugins/liteyuki_user/profile_manager.py @@ -5,15 +5,24 @@ from nonebot import require from src.utils.base.data import LiteModel, Database from src.utils.base.data_manager import User, user_db, group_db -from src.utils.base.language import Language, change_user_lang, get_all_lang, get_user_lang +from src.utils.base.language import ( + Language, + change_user_lang, + get_all_lang, + get_user_lang, +) from src.utils.base.ly_typing import T_Bot, T_MessageEvent from src.utils.message.message import MarkdownMessage as md + +# from src.utils.message.html_tool import md_to_pic from .const import representative_timezones_list from src.utils import event as event_utils + require("nonebot_plugin_alconna") from nonebot_plugin_alconna import Alconna, Args, Arparma, Subcommand, on_alconna + profile_alc = on_alconna( Alconna( "profile", @@ -28,7 +37,7 @@ profile_alc = on_alconna( alias=["g", "查询"], ), ), - aliases={"用户信息"} + aliases={"用户信息"}, ) @@ -42,13 +51,21 @@ class Profile(LiteModel): @profile_alc.handle() async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot): - user: User = user_db.where_one(User(), "user_id = ?", event_utils.get_user_id(event), - default=User(user_id=str(event_utils.get_user_id(event)))) + user: User = user_db.where_one( + User(), + "user_id = ?", + event_utils.get_user_id(event), + default=User(user_id=str(event_utils.get_user_id(event))), + ) ulang = get_user_lang(str(event_utils.get_user_id(event))) if result.subcommands.get("set"): if result.subcommands["set"].args.get("value"): # 对合法性进行校验后设置 - r = set_profile(result.args["key"], result.args["value"], str(event_utils.get_user_id(event))) + r = set_profile( + result.args["key"], + result.args["value"], + str(event_utils.get_user_id(event)), + ) if r: user.profile[result.args["key"]] = result.args["value"] user_db.save(user) # 数据库保存 @@ -56,18 +73,28 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot): ulang.get( "user.profile.set_success", ATTR=ulang.get(f"user.profile.{result.args['key']}"), - VALUE=result.args["value"] + VALUE=result.args["value"], ) ) else: - await profile_alc.finish(ulang.get("user.profile.set_failed", ATTR=ulang.get(f"user.profile.{result.args['key']}"))) + await profile_alc.finish( + ulang.get( + "user.profile.set_failed", + ATTR=ulang.get(f"user.profile.{result.args['key']}"), + ) + ) else: # 未输入值,尝试呼出菜单 menu = get_profile_menu(result.args["key"], ulang) if menu: await md.send_md(menu, bot, event=event) else: - await profile_alc.finish(ulang.get("user.profile.input_value", ATTR=ulang.get(f"user.profile.{result.args['key']}"))) + await profile_alc.finish( + ulang.get( + "user.profile.input_value", + ATTR=ulang.get(f"user.profile.{result.args['key']}"), + ) + ) user.profile[result.args["key"]] = result.args["value"] @@ -92,11 +119,16 @@ async def _(result: Arparma, event: T_MessageEvent, bot: T_Bot): continue val = profile.dict()[key] key_text = ulang.get(f"user.profile.{key}") - btn_set = md.btn_cmd(ulang.get("user.profile.edit"), f"profile set {key}", - enter=True if key in enter_attr else False) - reply += (f"\n**{key_text}** **{val}**\n" - f"\n> {ulang.get(f'user.profile.{key}.desc')}" - f"\n> {btn_set} \n\n***\n") + btn_set = md.btn_cmd( + ulang.get("user.profile.edit"), + f"profile set {key}", + enter=True if key in enter_attr else False, + ) + reply += ( + f"\n**{key_text}** **{val}**\n" + f"\n> {ulang.get(f'user.profile.{key}.desc')}" + f"\n> {btn_set} \n\n***\n" + ) await md.send_md(reply, bot, event=event) @@ -119,7 +151,9 @@ def get_profile_menu(key: str, ulang: Language) -> Optional[str]: reply = f"**{setting_name} {ulang.get('user.profile.settings')}**\n***\n" if key == "lang": for lang_code, lang_name in get_all_lang().items(): - btn_set_lang = md.btn_cmd(f"{lang_name}({lang_code})", f"profile set {key} {lang_code}") + btn_set_lang = md.btn_cmd( + f"{lang_name}({lang_code})", f"profile set {key} {lang_code}" + ) reply += f"\n{btn_set_lang}\n***\n" elif key == "timezone": for tz in representative_timezones_list: diff --git a/liteyuki/plugins/process_manager/__init__.py b/src/nonebot_plugins/pasyaut_plugin_upskin/__init__.py similarity index 100% rename from liteyuki/plugins/process_manager/__init__.py rename to src/nonebot_plugins/pasyaut_plugin_upskin/__init__.py diff --git a/src/nonebot_plugins/to_liteyuki.py b/src/nonebot_plugins/to_liteyuki.py new file mode 100644 index 0000000..706d62e --- /dev/null +++ b/src/nonebot_plugins/to_liteyuki.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/20 上午5:10 +@Author : snowykami +@Email : snowykami@outlook.com +@File : to_liteyuki.py +@Software: PyCharm +""" +import asyncio + +from nonebot import Bot, get_bot, on_message, get_driver +from nonebot.plugin import PluginMetadata +from nonebot.adapters.onebot.v11 import MessageEvent, Bot + +from liteyuki import Channel +from liteyuki.comm import get_channel +from liteyuki.comm.storage import shared_memory +from liteyuki.message.event import MessageEvent as LiteyukiMessageEvent + +__plugin_meta__ = PluginMetadata( + name="轻雪push", + description="把消息事件传递给轻雪框架进行处理", + usage="用户无需使用", +) + +recv_channel = Channel[LiteyukiMessageEvent](name="event_to_nonebot") + + +# @on_message().handle() +# async def _(bot: Bot, event: MessageEvent): +# liteyuki_event = LiteyukiMessageEvent( +# message_type=event.message_type, +# message=event.dict()["message"], +# raw_message=event.raw_message, +# data=event.dict(), +# bot_id=bot.self_id, +# user_id=str(event.user_id), +# session_id=str(event.user_id if event.message_type == "private" else event.group_id), +# session_type=event.message_type, +# receive_channel=recv_channel, +# ) +# shared_memory.publish("event_to_liteyuki", liteyuki_event) + + +# @get_driver().on_bot_connect +# async def _(): +# while True: +# event = await recv_channel.async_receive() +# bot: Bot = get_bot(event.bot_id) # type: ignore +# if event.message_type == "private": +# await bot.send_private_msg(user_id=int(event.session_id), message=event.data["message"]) +# elif event.message_type == "group": +# await bot.send_group_msg(group_id=int(event.session_id), message=event.data["message"]) diff --git a/src/resources/lagrange_sign/metadata.yml b/src/resources/lagrange_sign/metadata.yml deleted file mode 100644 index bbf05c9..0000000 --- a/src/resources/lagrange_sign/metadata.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: Sign Status -description: for Lagrange -version: 2024.4.26 \ No newline at end of file diff --git a/src/resources/lagrange_sign/templates/css/sign_status.css b/src/resources/lagrange_sign/templates/css/sign_status.css deleted file mode 100644 index af18499..0000000 --- a/src/resources/lagrange_sign/templates/css/sign_status.css +++ /dev/null @@ -1,4 +0,0 @@ -.sign-chart { - height: 400px; - background-color: rgba(255, 255, 255, 0.7); -} \ No newline at end of file diff --git a/src/resources/lagrange_sign/templates/js/sign_status.js b/src/resources/lagrange_sign/templates/js/sign_status.js deleted file mode 100644 index b8deaf1..0000000 --- a/src/resources/lagrange_sign/templates/js/sign_status.js +++ /dev/null @@ -1,75 +0,0 @@ -// 数据类型声明 -// import * as echarts from 'echarts'; - -let data = JSON.parse(document.getElementById("data").innerText) // object -const signChartDivTemplate = document.importNode(document.getElementById("sign-chart-template").content, true) -data.forEach((item) => { - let signChartDiv = signChartDivTemplate.cloneNode(true) - let chartID = item["name"] - // 初始化ECharts实例 - // 设置id - signChartDiv.querySelector(".sign-chart").id = chartID - document.body.appendChild(signChartDiv) - - let signChart = echarts.init(document.getElementById(chartID)) - let timeCount = [] - - item["counts"].forEach((count, index) => { - // 计算平均值,index - 1的count + index的count + index + 1的count /3 - if (index > 0) { - timeCount.push((item["counts"][index] - item["counts"][index - 1]) / (60*(item["times"][index] - item["times"][index - 1]))) - } - }) - - console.log(timeCount) - - signChart.setOption( - { - animation: false, - title: { - text: item["name"], - textStyle: { - color: '#000000' // 设置标题文本颜色为红色 - } - }, - xAxis: { - type: 'category', - data: item["times"].map(timestampToTime), - }, - yAxis: [ - { - type: 'value', - min: Math.min(...item["counts"]), - }, - { - type: 'value', - min: Math.min(...timeCount), - } - ], - series: [ - { - data: item["counts"], - type: 'line', - yAxisIndex: 0 - }, - { - data: timeCount, - type: 'line', - yAxisIndex: 1 - } - ] - } - ) -}) - - -function timestampToTime(timestamp) { - let date = new Date(timestamp * 1000) - let Y = date.getFullYear() + '-' - let M = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + '-' - let D = date.getDate() + ' ' - let h = date.getHours() + ':' - let m = date.getMinutes() + ':' - let s = date.getSeconds() - return M + D + h + m + s -} \ No newline at end of file diff --git a/src/resources/lagrange_sign/templates/sign_status.html b/src/resources/lagrange_sign/templates/sign_status.html deleted file mode 100644 index b9eafce..0000000 --- a/src/resources/lagrange_sign/templates/sign_status.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - Liteyuki Status - - - - - - - - -
{{ data | tojson }}
- - - - - \ No newline at end of file diff --git a/src/resources/vanilla_language/lang/zh-WY.lang b/src/resources/vanilla_language/lang/zh-WY.lang index 2379d98..772c5ac 100644 --- a/src/resources/vanilla_language/lang/zh-WY.lang +++ b/src/resources/vanilla_language/lang/zh-WY.lang @@ -1,10 +1,10 @@ language.name=文言 -log.debug=试言 -log.info=讯文 -log.warning=警示 -log.error=查误 -log.success=名成 +log.debug=试 +log.info=讯 +log.warning=警 +log.error=误 +log.success=成 liteyuki.restart=复启 liteyuki.restart_now=即复启 diff --git a/src/resources/vanilla_resource/templates/css/status.css b/src/resources/vanilla_resource/templates/css/status.css index 779ea84..8f177a9 100644 --- a/src/resources/vanilla_resource/templates/css/status.css +++ b/src/resources/vanilla_resource/templates/css/status.css @@ -113,6 +113,7 @@ z-index: 1; } +/* .disk-title { position: absolute; color: var(--main-text-color); @@ -126,6 +127,31 @@ max-width: calc(100% - 40px); z-index: 2; } +*/ + +.disk-name { + position: absolute; + color: var(--main-text-color); + font-size: 26px; + margin-left: 20px; + text-align: left; + white-space: normal; /* 允许换行 */ + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; /* 允许在单词内换行 */ + z-index: 2; +} + +.disk-details { + position: absolute; + right: 20px; + /* 调整位置以确保有足够的空间显示 */ + color: var(--main-text-color); + font-size: 24px; + text-align: right; + white-space: nowrap; + z-index: 2; +} #motto-text { font-size: 42px; diff --git a/src/resources/vanilla_resource/templates/js/status.js b/src/resources/vanilla_resource/templates/js/status.js index e6cfee4..f6df8fe 100644 --- a/src/resources/vanilla_resource/templates/js/status.js +++ b/src/resources/vanilla_resource/templates/js/status.js @@ -1,9 +1,9 @@ -const data = JSON.parse(document.getElementById('data').innerText); -const bot_data = data['bot']; // 机器人数据 -const hardwareData = data['hardware']; // 硬件数据 -const liteyukiData = data['liteyuki']; // LiteYuki数据 -const localData = data['localization']; // 本地化语言数据 -const motto_ = data['motto']; // 言论数据 +const data = JSON.parse(document.getElementById("data").innerText); +const bot_data = data["bot"]; // 机器人数据 +const hardwareData = data["hardware"]; // 硬件数据 +const liteyukiData = data["liteyuki"]; // LiteYuki数据 +const localData = data["localization"]; // 本地化语言数据 +const motto_ = data["motto"]; // 言论数据 /** * 创建CPU/内存/交换饼图 @@ -16,52 +16,54 @@ function createPieChartOption(title, data) { animation: false, title: { text: title, - left: 'center', - top: 'center', + left: "center", + top: "center", textStyle: { - color: '#000', + color: "#000", fontSize: 30, - lineHeight: 36 - } + lineHeight: 36, + }, }, tooltip: { show: true, - trigger: 'item', - backgroundColor: '#000', + trigger: "item", + backgroundColor: "#000", }, - color: data.length === 3 ? ['#053349', '#007ebd', "#00000044"] : ['#007ebd', '#00000044'], + color: + data.length === 3 + ? ["#053349", "#007ebd", "#00000044"] + : ["#007ebd", "#00000044"], series: [ { - name: 'info', - type: 'pie', - radius: ['80%', '100%'], - center: ['50%', '50%'], + name: "info", + type: "pie", + radius: ["80%", "100%"], + center: ["50%", "50%"], itemStyle: { normal: { label: { - show: false + show: false, }, labelLine: { - show: false - } + show: false, + }, }, emphasis: { label: { show: true, textStyle: { - fontSize: '50', - fontWeight: 'bold' - } - } - } + fontSize: "50", + fontWeight: "bold", + }, + }, + }, }, - data: data - } - ] - } + data: data, + }, + ], + }; } - function convertSize(size, precision = 2, addUnit = true, suffix = " X字节") { let isNegative = size < 0; size = Math.abs(size); @@ -81,7 +83,7 @@ function convertSize(size, precision = 2, addUnit = true, suffix = " X字节") { } if (addUnit) { - return size.toFixed(precision) + suffix.replace('X', unit); + return size.toFixed(precision) + suffix.replace("X", unit); } else { return size; } @@ -92,206 +94,260 @@ function convertSize(size, precision = 2, addUnit = true, suffix = " X字节") { * @param title * @param percent 数据 */ -function createBarChart(title, percent) { +function createBarChart(title, percent, name) { // percent为百分比,最大值为100 - let diskDiv = document.createElement('div') - diskDiv.setAttribute('class', 'disk-info') - diskDiv.style.marginBottom = '20px' + let diskDiv = document.createElement("div"); + diskDiv.setAttribute("class", "disk-info"); + diskDiv.style.marginBottom = "20px"; diskDiv.innerHTML = `
${title}
- ` + `; + updateDiskNameWidth(diskDiv); - return diskDiv + return diskDiv; +} + +// 更新 .disk-name 宽度 +function updateDiskNameWidth(diskInfoElement) { + let diskDetails = diskInfoElement.querySelector(".disk-details"); + let diskName = diskInfoElement.querySelector(".disk-name"); + let detailsWidth = diskDetails.offsetWidth; + let parentWidth = diskInfoElement.offsetWidth; + + let nameMaxWidth = parentWidth - detailsWidth - 20 - 40; + diskName.style.maxWidth = `${nameMaxWidth}px`; } function secondsToTextTime(seconds) { - let days = Math.floor(seconds / 86400) - let hours = Math.floor((seconds % 86400) / 3600) - let minutes = Math.floor((seconds % 3600) / 60) - let seconds_ = Math.floor(seconds % 60) - return `${days}${localData['days']} ${hours}${localData['hours']} ${minutes}${localData['minutes']} ${seconds_}${localData['seconds']}` + let days = Math.floor(seconds / 86400); + let hours = Math.floor((seconds % 86400) / 3600); + let minutes = Math.floor((seconds % 3600) / 60); + let seconds_ = Math.floor(seconds % 60); + return `${days}${localData["days"]} ${hours}${localData["hours"]} ${minutes}${localData["minutes"]} ${seconds_}${localData["seconds"]}`; } // 主函数 function main() { // 添加机器人信息 - bot_data['bots'].forEach( - (bot) => { - let botInfoDiv = document.importNode(document.getElementById('bot-template').content, true) // 复制模板 + bot_data["bots"].forEach((bot) => { + let botInfoDiv = document.importNode( + document.getElementById("bot-template").content, + true + ); // 复制模板 + // 设置机器人信息 + botInfoDiv.className = "info-box bot-info"; - // 设置机器人信息 - botInfoDiv.className = 'info-box bot-info' - - botInfoDiv.querySelector('.bot-icon-img').setAttribute('src', bot['icon']) - botInfoDiv.querySelector('.bot-name').innerText = bot['name'] - let tagArray = [ - bot['protocol_name'], - `${bot['app_name']}`, - `${localData['groups']}${bot['groups']}`, - `${localData['friends']}${bot['friends']}`, - `${localData['message_sent']}${bot['message_sent']}`, - `${localData['message_received']}${bot['message_received']}`, - ] - // 添加一些标签 - tagArray.forEach( - (tag, index) => { - let tagSpan = document.createElement('span') - tagSpan.className = 'bot-tag' - tagSpan.innerText = tag - // 给最后一个标签不添加后缀 - tagSpan.setAttribute('suffix', (index === 0) || (tag[0] == '\n') ? '0' : '1') - botInfoDiv.querySelector('.bot-tags').appendChild(tagSpan) - } - ) - document.body.insertBefore(botInfoDiv, document.getElementById('hardware-info')) // 插入对象 - - } - ) + botInfoDiv.querySelector(".bot-icon-img").setAttribute("src", bot["icon"]); + botInfoDiv.querySelector(".bot-name").innerText = bot["name"]; + let tagArray = [ + bot["protocol_name"], + `${bot["app_name"]}`, + `${localData["groups"]}${bot["groups"]}`, + `${localData["friends"]}${bot["friends"]}`, + `${localData["message_sent"]}${bot["message_sent"]}`, + `${localData["message_received"]}${bot["message_received"]}`, + ]; + // 添加一些标签 + tagArray.forEach((tag, index) => { + let tagSpan = document.createElement("span"); + tagSpan.className = "bot-tag"; + tagSpan.innerText = tag; + // 给最后一个标签不添加后缀 + tagSpan.setAttribute("suffix", index === 0 || tag[0] == "\n" ? "0" : "1"); + botInfoDiv.querySelector(".bot-tags").appendChild(tagSpan); + }); + document.body.insertBefore( + botInfoDiv, + document.getElementById("hardware-info") + ); // 插入对象 + }); // 添加轻雪信息 - let liteyukiInfoDiv = document.importNode(document.getElementById('bot-template').content, true) // 复制模板 - liteyukiInfoDiv.className = 'info-box bot-info' - liteyukiInfoDiv.querySelector('.bot-icon-img').setAttribute('src', './img/litetrimo.png') - liteyukiInfoDiv.querySelector('.bot-name').innerText = `${liteyukiData['name']} - 睿乐` + let liteyukiInfoDiv = document.importNode( + document.getElementById("bot-template").content, + true + ); // 复制模板 + liteyukiInfoDiv.className = "info-box bot-info"; + liteyukiInfoDiv + .querySelector(".bot-icon-img") + .setAttribute("src", "./img/litetrimo.png"); + liteyukiInfoDiv.querySelector( + ".bot-name" + ).innerText = `${liteyukiData["name"]} - 睿乐`; let tagArray = [ - `灵温 ${liteyukiData['version']}`, - `Nonebot ${liteyukiData['nonebot']}`, - `${liteyukiData['python']}`, - liteyukiData['system'], - `${localData['plugins']}${liteyukiData['plugins']}`, - `${localData['resources']}${liteyukiData['resources']}`, - `${localData['bots']}${liteyukiData['bots']}`, - `${localData['runtime']} ${secondsToTextTime(liteyukiData['runtime'])}`, - ] - tagArray.forEach( - (tag, index) => { - let tagSpan = document.createElement('span') - tagSpan.className = 'bot-tag' - tagSpan.innerText = tag - // 给最后一个标签不添加后缀 - tagSpan.setAttribute('suffix', (index === 0) || (tag[0] == '\n') ? '0' : '1') - liteyukiInfoDiv.querySelector('.bot-tags').appendChild(tagSpan) - } - ) - document.body.insertBefore(liteyukiInfoDiv, document.getElementById('hardware-info')) // 插入对象 + `灵温 ${liteyukiData["version"]}`, + `Nonebot ${liteyukiData["nonebot"]}`, + `${liteyukiData["python"]}`, + liteyukiData["system"], + `${localData["plugins"]}${liteyukiData["plugins"]}`, + `${localData["resources"]}${liteyukiData["resources"]}`, + `${localData["bots"]}${liteyukiData["bots"]}`, + `${localData["runtime"]} ${secondsToTextTime(liteyukiData["runtime"])}`, + ]; + tagArray.forEach((tag, index) => { + let tagSpan = document.createElement("span"); + tagSpan.className = "bot-tag"; + tagSpan.innerText = tag; + // 给最后一个标签不添加后缀 + tagSpan.setAttribute("suffix", index === 0 || tag[0] == "\n" ? "0" : "1"); + liteyukiInfoDiv.querySelector(".bot-tags").appendChild(tagSpan); + }); + document.body.insertBefore( + liteyukiInfoDiv, + document.getElementById("hardware-info") + ); // 插入对象 // 添加硬件信息 - const cpuData = hardwareData['cpu'] - const memData = hardwareData['memory'] - const swapData = hardwareData['swap'] + const cpuData = hardwareData["cpu"]; + const memData = hardwareData["memory"]; + const swapData = hardwareData["swap"]; const cpuTagArray = [ - cpuData['name'], - `${cpuData['cores']}${localData['cores']} ${cpuData['threads']}${localData['threads']}`, - `${(cpuData['freq'] / 1000).toFixed(2)}吉赫兹` - ] + cpuData["name"], + `${cpuData["cores"]}${localData["cores"]} ${cpuData["threads"]}${localData["threads"]}`, + `${(cpuData["freq"] / 1000).toFixed(2)}吉赫兹`, + ]; const memTagArray = [ - `${localData['process']} ${convertSize(memData['usedProcess'])}`, - `${localData['used']} ${convertSize(memData['used'])}`, - `${localData['free']} ${convertSize(memData['free'])}`, - `${localData['total']} ${convertSize(memData['total'])}` - ] + `${localData["process"]} ${convertSize(memData["usedProcess"])}`, + `${localData["used"]} ${convertSize(memData["used"])}`, + `${localData["free"]} ${convertSize(memData["free"])}`, + `${localData["total"]} ${convertSize(memData["total"])}`, + ]; const swapTagArray = [ - `${localData['used']} ${convertSize(swapData['used'])}`, - `${localData['free']} ${convertSize(swapData['free'])}`, - `${localData['total']} ${convertSize(swapData['total'])}` - ] - let cpuDeviceInfoDiv = document.importNode(document.getElementById('device-info').content, true) - let memDeviceInfoDiv = document.importNode(document.getElementById('device-info').content, true) - let swapDeviceInfoDiv = document.importNode(document.getElementById('device-info').content, true) + `${localData["used"]} ${convertSize(swapData["used"])}`, + `${localData["free"]} ${convertSize(swapData["free"])}`, + `${localData["total"]} ${convertSize(swapData["total"])}`, + ]; + let cpuDeviceInfoDiv = document.importNode( + document.getElementById("device-info").content, + true + ); + let memDeviceInfoDiv = document.importNode( + document.getElementById("device-info").content, + true + ); + let swapDeviceInfoDiv = document.importNode( + document.getElementById("device-info").content, + true + ); - cpuDeviceInfoDiv.querySelector('.device-info').setAttribute('id', 'cpu-info') - memDeviceInfoDiv.querySelector('.device-info').setAttribute('id', 'mem-info') - swapDeviceInfoDiv.querySelector('.device-info').setAttribute('id', 'swap-info') - cpuDeviceInfoDiv.querySelector('.device-chart').setAttribute('id', 'cpu-chart') - memDeviceInfoDiv.querySelector('.device-chart').setAttribute('id', 'mem-chart') - swapDeviceInfoDiv.querySelector('.device-chart').setAttribute('id', 'swap-chart') + cpuDeviceInfoDiv.querySelector(".device-info").setAttribute("id", "cpu-info"); + memDeviceInfoDiv.querySelector(".device-info").setAttribute("id", "mem-info"); + swapDeviceInfoDiv + .querySelector(".device-info") + .setAttribute("id", "swap-info"); + cpuDeviceInfoDiv + .querySelector(".device-chart") + .setAttribute("id", "cpu-chart"); + memDeviceInfoDiv + .querySelector(".device-chart") + .setAttribute("id", "mem-chart"); + swapDeviceInfoDiv + .querySelector(".device-chart") + .setAttribute("id", "swap-chart"); let devices = { - 'cpu': cpuDeviceInfoDiv, - 'mem': memDeviceInfoDiv, - 'swap': swapDeviceInfoDiv - } + cpu: cpuDeviceInfoDiv, + mem: memDeviceInfoDiv, + swap: swapDeviceInfoDiv, + }; // 遍历添加标签 for (let device in devices) { - let tagArray = [] + let tagArray = []; switch (device) { - case 'cpu': - tagArray = cpuTagArray - break - case 'mem': - tagArray = memTagArray - break - case 'swap': - tagArray = swapTagArray - break + case "cpu": + tagArray = cpuTagArray; + break; + case "mem": + tagArray = memTagArray; + break; + case "swap": + tagArray = swapTagArray; + break; } - tagArray.forEach( - (tag, index) => { - let tagDiv = document.createElement('div') - tagDiv.className = 'device-tag' - tagDiv.innerText = tag - // 给最后一个标签不添加后缀 - tagDiv.setAttribute('suffix', index === tagArray.length - 1 ? '0' : '1') - devices[device].querySelector('.device-tags').appendChild(tagDiv) - } - ) + tagArray.forEach((tag, index) => { + let tagDiv = document.createElement("div"); + tagDiv.className = "device-tag"; + tagDiv.innerText = tag; + // 给最后一个标签不添加后缀 + tagDiv.setAttribute("suffix", index === tagArray.length - 1 ? "0" : "1"); + devices[device].querySelector(".device-tags").appendChild(tagDiv); + }); } - // 插入 - document.getElementById('hardware-info').appendChild(cpuDeviceInfoDiv) - document.getElementById('hardware-info').appendChild(memDeviceInfoDiv) - document.getElementById('hardware-info').appendChild(swapDeviceInfoDiv) + document.getElementById("hardware-info").appendChild(cpuDeviceInfoDiv); + document.getElementById("hardware-info").appendChild(memDeviceInfoDiv); + document.getElementById("hardware-info").appendChild(swapDeviceInfoDiv); - let cpuChart = echarts.init(document.getElementById('cpu-chart')) - let memChart = echarts.init(document.getElementById('mem-chart')) - let swapChart = echarts.init(document.getElementById('swap-chart')) + let cpuChart = echarts.init(document.getElementById("cpu-chart")); + let memChart = echarts.init(document.getElementById("mem-chart")); + let swapChart = echarts.init(document.getElementById("swap-chart")); + cpuChart.setOption( + createPieChartOption( + `${localData["cpu"]}\n${cpuData["percent"].toFixed(1)}%`, + [ + { name: "used", value: cpuData["percent"] }, + { name: "free", value: 100 - cpuData["percent"] }, + ] + ) + ); - cpuChart.setOption(createPieChartOption(`${localData['cpu']}\n${cpuData['percent'].toFixed(1)}%`, [ - { name: 'used', value: cpuData['percent'] }, - { name: 'free', value: 100 - cpuData['percent'] } - ])) - - memChart.setOption(createPieChartOption(`${localData['memory']}\n${memData['percent'].toFixed(1)}%`, [ - { name: 'process', value: memData['usedProcess'] }, - { name: 'used', value: memData['used'] - memData['usedProcess'] }, - { name: 'free', value: memData['free'] } - ])) - - - swapChart.setOption(createPieChartOption(`${localData['swap']}\n${swapData['percent'].toFixed(1)}%`, [ - { name: 'used', value: swapData['used'] }, - { name: 'free', value: swapData['free'] } - ])) + memChart.setOption( + createPieChartOption( + `${localData["memory"]}\n${memData["percent"].toFixed(1)}%`, + [ + { name: "process", value: memData["usedProcess"] }, + { name: "used", value: memData["used"] - memData["usedProcess"] }, + { name: "free", value: memData["free"] }, + ] + ) + ); + swapChart.setOption( + createPieChartOption( + `${localData["swap"]}\n${swapData["percent"].toFixed(1)}%`, + [ + { name: "used", value: swapData["used"] }, + { name: "free", value: swapData["free"] }, + ] + ) + ); // 磁盘信息 - const diskData = hardwareData['disk'] - diskData.forEach( - (disk) => { - let diskTitle = `${disk['name']} ${localData['free']} ${convertSize(disk['free'])} ${localData['total']} ${convertSize(disk['total'])}` - // 最后一个把margin-bottom去掉 - let diskDiv = createBarChart(diskTitle, disk['percent']) - if (disk === diskData[diskData.length - 1]) { - diskDiv.style.marginBottom = '0' - } - document.getElementById('disk-info').appendChild(createBarChart(diskTitle, disk['percent'])) - }) + const diskData = hardwareData["disk"]; + diskData.forEach((disk) => { + let diskTitle = `${localData['free']} ${convertSize(disk['free'])} ${localData['total']} ${convertSize(disk['total'])}`; + let diskDiv = createBarChart(diskTitle, disk['percent'], disk['name']); + // 最后一个把margin-bottom去掉 + if (disk === diskData[diskData.length - 1]) { + diskDiv.style.marginBottom = "0"; + } + document.getElementById('disk-info').appendChild(diskDiv); + }); // 随机一言 - let mottoText = motto_['text'] - let mottoFrom = motto_['source'] - document.getElementById('motto-text').innerText = mottoText - document.getElementById('motto-from').innerText = mottoFrom + let mottoText = motto_["text"]; + let mottoFrom = motto_["source"]; + document.getElementById("motto-text").innerText = mottoText; + document.getElementById("motto-from").innerText = mottoFrom; // 致谢 - document.getElementById('addition-info').innerText = '感谢 锅炉 云裳工作室 提供服务器支持' - + document.getElementById("addition-info").innerText = + "感谢 锅炉 云裳工作室 提供服务器支持"; } -main() \ No newline at end of file +main(); +/* +// 窗口大小改变监听器 -- Debug +window.addEventListener('resize', () => { + const diskInfos = document.querySelectorAll('.disk-info'); + diskInfos.forEach(diskInfo => { + updateDiskNameWidth(diskInfo); + }); +}); +*/ \ No newline at end of file diff --git a/src/resources/vanilla_resource/unsorted/plugins.json b/src/resources/vanilla_resource/unsorted/plugins.json index 89528d6..0ae68c3 100644 --- a/src/resources/vanilla_resource/unsorted/plugins.json +++ b/src/resources/vanilla_resource/unsorted/plugins.json @@ -837,22 +837,6 @@ "time": "2023-06-20T16:04:40.706727Z", "skip_test": false }, - { - "module_name": "nonebot_plugin_htmlrender", - "project_link": "nonebot-plugin-htmlrender", - "name": "nonebot-plugin-htmlrender", - "desc": "通过浏览器渲染图片", - "author": "kexue-z", - "homepage": "https://github.com/kexue-z/nonebot-plugin-htmlrender", - "tags": [], - "is_official": false, - "type": "library", - "supported_adapters": null, - "valid": true, - "version": "0.3.1", - "time": "2024-03-14T08:47:15.010445Z", - "skip_test": false - }, { "module_name": "nonebot_plugin_admin", "project_link": "nonebot-plugin-admin", @@ -8718,31 +8702,6 @@ "time": "2023-07-14T10:32:08.006009Z", "skip_test": false }, - { - "module_name": "nonebot_plugin_templates", - "project_link": "nonebot-plugin-templates", - "name": "templates_render", - "desc": "使用htmlrender和jinja2渲染,使用构建的menu,card或dict进行模板渲染", - "author": "canxin121", - "homepage": "https://github.com/canxin121/nonebot_plugin_templates", - "tags": [ - { - "label": "模板渲染", - "color": "#eacd52" - }, - { - "label": "图片生成", - "color": "#adea52" - } - ], - "is_official": false, - "type": "library", - "supported_adapters": null, - "valid": true, - "version": "0.1.6", - "time": "2023-08-24T09:56:33.184091Z", - "skip_test": false - }, { "module_name": "nonebot_plugin_pokesomeone", "project_link": "nonebot-plugin-pokesomeone", diff --git a/src/utils/__init__.py b/src/utils/__init__.py index f0ac4ed..d30e26b 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,13 +1,9 @@ -import json -import os.path -import platform import sys -import time import nonebot __NAME__ = "尹灵温|轻雪-睿乐" -__VERSION__ = "6.3.4" # 60201 +__VERSION__ = "6.3.9" # 60201 import requests @@ -15,36 +11,10 @@ from src.utils.base.config import load_from_yaml, config from src.utils.base.log import init_log from git import Repo - major, minor, patch = map(int, __VERSION__.split(".")) __VERSION_I__ = 99000000 + major * 10000 + minor * 100 + patch -def register_bot(): - url = "https://api.liteyuki.icu/register" - data = { - "name": __NAME__, - "version": __VERSION__, - "version_i": __VERSION_I__, - "python": f"{platform.python_implementation()} {platform.python_version()}", - "os": f"{platform.system()} {platform.version()} {platform.machine()}", - } - try: - nonebot.logger.info("正在等待 Liteyuki 注册服务器…") - resp = requests.post(url, json=data, timeout=(10, 15)) - if resp.status_code == 200: - data = resp.json() - if liteyuki_id := data.get("liteyuki_id"): - with open("data/liteyuki/liteyuki.json", "wb") as f: - f.write(json.dumps(data).encode("utf-8")) - nonebot.logger.success(f"成功将 {liteyuki_id} 注册到 Liteyuki 服务器") - else: - raise ValueError(f"无法向 Liteyuki 服务器注册:{data}") - - except Exception as e: - nonebot.logger.warning(f"向 Liteyuki 服务器注册失败,但无所谓:{e}") - - def init(): """ 初始化 @@ -64,25 +34,15 @@ def init(): repo = Repo(".") except Exception as e: nonebot.logger.error( - f"无法读取 Git 仓库 {e},你是否是从仓库内下载的Zip文件?请使用git clone。" + f"无法读取 Git 仓库 `{e}`,你是否是从仓库直接下载的Zip文件?请使用git clone。" ) # temp_data: TempConfig = common_db.where_one(TempConfig(), default=TempConfig()) # temp_data.data["start_time"] = time.time() # common_db.save(temp_data) - # 在加载完成语言后再初始化日志 - nonebot.logger.info("尹灵温 正在初始化…") - - if not os.path.exists("data/liteyuki/liteyuki.json"): - register_bot() - - if not os.path.exists("pyproject.toml"): - with open("pyproject.toml", "w", encoding="utf-8") as f: - f.write("[tool.nonebot]\n") - nonebot.logger.info( - "正在 {} Python{}.{}.{} 上运行 尹灵温".format( + "正在 {} Python{}.{}.{} 上运行 尹灵温-NoneBot".format( sys.executable, sys.version_info.major, sys.version_info.minor, diff --git a/src/utils/base/__init__.py b/src/utils/base/__init__.py index 5f14052..a0b0a58 100644 --- a/src/utils/base/__init__.py +++ b/src/utils/base/__init__.py @@ -1,7 +1,6 @@ import threading -from nonebot import logger -from liteyuki.comm.channel import get_channel +from liteyuki.comm.channel import active_channel def reload(delay: float = 0.0, receiver: str = "nonebot"): @@ -14,13 +13,9 @@ def reload(delay: float = 0.0, receiver: str = "nonebot"): Returns: """ - chan = get_channel(receiver + "-active") - if chan is None: - logger.error(f"未见 Channel {receiver}-active 以至无法重载") - return if delay > 0: - threading.Timer(delay, chan.send, args=(1,)).start() + threading.Timer(delay, active_channel.send, args=(1,)).start() return else: - chan.send(1) + active_channel.send(1) diff --git a/src/utils/base/config.py b/src/utils/base/config.py index 8e025ab..85737e4 100644 --- a/src/utils/base/config.py +++ b/src/utils/base/config.py @@ -1,4 +1,5 @@ import os +import platform from typing import List import nonebot @@ -7,6 +8,7 @@ from pydantic import BaseModel from ..message.tools import random_hex_string + config = {} # 全局配置,确保加载后读取 @@ -29,23 +31,37 @@ class BasicConfig(BaseModel): superusers: list[str] = [] command_start: list[str] = ["/", ""] nickname: list[str] = [f"灵温-{random_hex_string(6)}"] + default_language: str = "zh-WY" satori: SatoriConfig = SatoriConfig() data_path: str = "data/liteyuki" + chromium_path: str = ( + "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" # type: ignore + if platform.system() == "Darwin" + else ( + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" + if platform.system() == "Windows" + else "/usr/bin/chromium-browser" + ) + ) -def load_from_yaml(file: str) -> dict: +def load_from_yaml(file_: str) -> dict: global config - nonebot.logger.debug("Loading config from %s" % file) - if not os.path.exists(file): - nonebot.logger.warning(f"未找到配置文件 {file} ,已创建默认配置,请修改后重启。") - with open(file, "w", encoding="utf-8") as f: + nonebot.logger.debug("正在从 {} 中加载配置项".format(file_)) + if not os.path.exists(file_): + nonebot.logger.warning( + f"未寻得配置文件 {file_} ,已以默认配置创建,请在重启后更改为你所需的内容。" + ) + with open(file_, "w", encoding="utf-8") as f: yaml.dump(BasicConfig().dict(), f, default_flow_style=False) - with open(file, "r", encoding="utf-8") as f: + with open(file_, "r", encoding="utf-8") as f: conf = init_conf(yaml.load(f, Loader=yaml.FullLoader)) config = conf if conf is None: - nonebot.logger.warning(f"配置文件 {file} 为空,已创建默认配置,请修改后重启。") + nonebot.logger.warning( + f"配置文件 {file_} 为空,已以默认配置创建,请在重启后更改为你所需的内容。" + ) conf = BasicConfig().dict() return conf @@ -75,7 +91,6 @@ def get_config(key: str, default=None): return default - def init_conf(conf: dict) -> dict: """ 初始化配置文件,确保配置文件中的必要字段存在,且不会冲突 diff --git a/src/utils/base/data.py b/src/utils/base/data.py index 7536984..c3e3e95 100644 --- a/src/utils/base/data.py +++ b/src/utils/base/data.py @@ -30,7 +30,7 @@ class Database: os.makedirs(os.path.dirname(db_name)) self.db_name = db_name - self.conn = sqlite3.connect(db_name) + self.conn = sqlite3.connect(db_name, check_same_thread=False) self.cursor = self.conn.cursor() self._on_save_callbacks = [] @@ -94,7 +94,7 @@ class Database: f"数据库 Selecting {model.TABLE_NAME} WHERE {condition.replace('?', '%s') % args}" ) if not table_name: - raise ValueError(f"数据模型{model_type.__name__}未提供表名") + raise ValueError(f"数据模型 {model_type.__name__} 未提供表名") # condition = f"WHERE {condition}" # print(f"SELECT * FROM {table_name} {condition}", args) @@ -118,7 +118,7 @@ class Database: ] def save(self, *args: LiteModel): - """增/改操作 + self.returns_ = """增/改操作 Args: *args: Returns: @@ -126,7 +126,7 @@ class Database: table_list = [ item[0] for item in self.cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table'" + "SELECT name FROM sqlite_master WHERE type ='table'" ).fetchall() ] for model in args: @@ -158,7 +158,7 @@ class Database: new_obj[field] = value else: raise ValueError( - f"数据模型{table_name}包含不支持的数据类型,字段:{field} 值:{value} 值类型:{type(value)}" + f"数据模型 {table_name} 包含不支持的数据类型,字段:{field} 值:{value} 值类型:{type(value)}" ) if table_name: fields, values = [], [] @@ -273,9 +273,9 @@ class Database: """ table_name = model.TABLE_NAME - logger.debug(f"数据库 Deleting {model} WHERE {condition} {args}") + logger.debug(f"Deleting {model} WHERE {condition} {args}") if not table_name: - raise ValueError(f"数据模型{model.__class__.__name__}未提供表名") + raise ValueError(f"数据模型 {model.__class__.__name__} 未提供表名") if model.id is not None: condition = f"id = {model.id}" if not condition and not allow_empty: @@ -297,7 +297,7 @@ class Database: """ for model in args: if not model.TABLE_NAME: - raise ValueError(f"数据模型{type(model).__name__}未提供表名") + raise ValueError(f"数据模型 {type(model).__name__} 未提供表名") # 若无则创建表 self.cursor.execute( diff --git a/src/utils/base/data_manager.py b/src/utils/base/data_manager.py index 914f1cf..7b26722 100644 --- a/src/utils/base/data_manager.py +++ b/src/utils/base/data_manager.py @@ -2,9 +2,8 @@ import os from pydantic import Field -from .data import Database, LiteModel, Database +from .data import Database, LiteModel -print("导入数据库模块") DATA_PATH = "data/liteyuki" user_db: Database = Database(os.path.join(DATA_PATH, "users.ldb")) group_db: Database = Database(os.path.join(DATA_PATH, "groups.ldb")) @@ -64,7 +63,7 @@ def auto_migrate(): user_db.auto_migrate(User()) group_db.auto_migrate(Group()) plugin_db.auto_migrate(InstalledPlugin(), GlobalPlugin()) - common_db.auto_migrate(GlobalPlugin(), StoredConfig(), TempConfig()) + common_db.auto_migrate(GlobalPlugin(), TempConfig()) auto_migrate() diff --git a/src/utils/base/ly_api.py b/src/utils/base/ly_api.py deleted file mode 100644 index 5ac2220..0000000 --- a/src/utils/base/ly_api.py +++ /dev/null @@ -1,92 +0,0 @@ -import json -import os.path -import platform - -import aiohttp -import nonebot -import psutil -import requests - -from .config import load_from_yaml -from .. import __NAME__, __VERSION_I__, __VERSION__ - - -class LiteyukiAPI: - def __init__(self): - self.liteyuki_id = None - if os.path.exists("data/liteyuki/liteyuki.json"): - with open("data/liteyuki/liteyuki.json", "rb") as f: - self.data = json.loads(f.read()) - self.liteyuki_id = self.data.get("liteyuki_id") - self.report = load_from_yaml("config.yml").get("auto_report", True) - - if self.report: - nonebot.logger.info("已启用自动上报") - - @property - def device_info(self) -> dict: - """ - 获取设备信息 - Returns: - - """ - return { - "name": __NAME__, - "version": __VERSION__, - "version_i": __VERSION_I__, - "python": f"{platform.python_implementation()} {platform.python_version()}", - "os": f"{platform.system()} {platform.version()} {platform.machine()}", - "cpu": f"{psutil.cpu_count(logical=False)}c{psutil.cpu_count()}t{psutil.cpu_freq().current}MHz", - "memory_total": f"{psutil.virtual_memory().total / 1024 ** 3:.2f}吉字节", - "memory_used": f"{psutil.virtual_memory().used / 1024 ** 3:.2f}吉字节", - "memory_bot": f"{psutil.Process(os.getpid()).memory_info().rss / 1024 ** 2:.2f}兆字节", - "disk": f"{psutil.disk_usage('/').total / 1024 ** 3:.2f}吉字节", - } - - def bug_report(self, content: str): - """ - 提交bug报告 - Args: - content: - - Returns: - - """ - if self.report: - nonebot.logger.warning(f"正在上报查误:{content}") - url = "https://api.liteyuki.icu/bug_report" - data = { - "liteyuki_id": self.liteyuki_id, - "content": content, - "device_info": self.device_info, - } - resp = requests.post(url, json=data) - if resp.status_code == 200: - nonebot.logger.success( - f"成功上报差误信息,报文ID为:{resp.json().get('report_id')}" - ) - else: - nonebot.logger.error(f"差误上报错误:{resp.text}") - else: - nonebot.logger.warning(f"已禁用自动上报:{content}") - - def register(self): - pass - - async def heartbeat_report(self): - """ - 提交心跳,预留接口 - Returns: - - """ - url = "https://api.liteyuki.icu/heartbeat" - data = { - "liteyuki_id": self.liteyuki_id, - "version": __VERSION__, - } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=data) as resp: - if resp.status == 200: - nonebot.logger.success("心跳成功送达。") - else: - nonebot.logger.error("休克:{}".format(await resp.text())) diff --git a/src/utils/base/resource.py b/src/utils/base/resource.py index 717ac41..7aa52d8 100644 --- a/src/utils/base/resource.py +++ b/src/utils/base/resource.py @@ -3,7 +3,9 @@ import os import shutil import zipfile from typing import Any +from pathlib import Path +# import aiofiles import nonebot import yaml @@ -12,8 +14,8 @@ from .language import Language, get_default_lang_code from .ly_function import loaded_functions _loaded_resource_packs: list["ResourceMetadata"] = [] # 按照加载顺序排序 -temp_resource_root = "data/liteyuki/resources" -temp_extract_root = "data/liteyuki/temp" +temp_resource_root = Path("data/liteyuki/resources") +temp_extract_root = Path("data/liteyuki/temp") lang = Language(get_default_lang_code()) @@ -50,60 +52,139 @@ def load_resource_from_dir(path: str): for root, dirs, files in os.walk(path): for file in files: relative_path = os.path.relpath(os.path.join(root, file), path) - copy_file(os.path.join(root, file), os.path.join(temp_resource_root, relative_path)) + copy_file( + os.path.join(root, file), + os.path.join(temp_resource_root, relative_path), + ) metadata["path"] = path metadata["folder"] = os.path.basename(path) if os.path.exists(os.path.join(path, "lang")): # 加载语言 from src.utils.base.language import load_from_dir + load_from_dir(os.path.join(path, "lang")) if os.path.exists(os.path.join(path, "functions")): # 加载功能 from src.utils.base.ly_function import load_from_dir + load_from_dir(os.path.join(path, "functions")) if os.path.exists(os.path.join(path, "word_bank")): # 加载词库 from src.utils.base.word_bank import load_from_dir + load_from_dir(os.path.join(path, "word_bank")) _loaded_resource_packs.insert(0, ResourceMetadata(**metadata)) -def get_path(path: str, abs_path: bool = True, default: Any = None, debug: bool = False) -> str | Any: +def get_path( + path: os.PathLike[str,] | Path | str, + abs_path: bool = True, + default: Any = None, + debug: bool = False, +) -> str | Any: """ - 获取资源包中的文件 + 获取资源包中的路径,且该路径必须存在 Args: - debug: 启用调试,每次都会先重载资源 + path: 相对路径 abs_path: 是否返回绝对路径 - default: 默认 - path: 文件相对路径 - Returns: 文件绝对路径 + default: 默认解,当该路径不存在时使用 + debug: 启用调试,每次都会先重载资源 + Returns: 所需求之路径 """ if debug: - nonebot.logger.debug("Enable resource debug, Reloading resources") + nonebot.logger.debug("由于已启用资源路径调试,正在重载资源") load_resources() - resource_relative_path = os.path.join(temp_resource_root, path) - if os.path.exists(resource_relative_path): - return os.path.abspath(resource_relative_path) if abs_path else resource_relative_path + resource_relative_path = temp_resource_root / path + if resource_relative_path.exists(): + return str( + resource_relative_path.resolve() if abs_path else resource_relative_path + ) else: return default -def get_files(path: str, abs_path: bool = False) -> list[str]: +def get_resource_path( + path: os.PathLike[str,] | Path | str, + abs_path: bool = True, + only_exist: bool = False, + default: Any = None, + debug: bool = False, +) -> Path: """ - 获取资源包中一个文件夹的所有文件 + 获取资源包中的路径 Args: - abs_path: - path: 文件夹相对路径 - Returns: 文件绝对路径 + path: 相对路径 + abs_path: 是否返回绝对路径 + only_exist: 检查该路径是否存在 + default: [当 `only_exist` 为 **真** 时启用]默认解,当该路径不存在时使用 + debug: 启用调试,每次都会先重载资源 + Returns: 所需求之路径 """ - resource_relative_path = os.path.join(temp_resource_root, path) - if os.path.exists(resource_relative_path): - return [os.path.abspath(os.path.join(resource_relative_path, file)) if abs_path else os.path.join(resource_relative_path, file) for file in - os.listdir(resource_relative_path)] + if debug: + nonebot.logger.debug("由于已启用资源路径调试,正在重载资源") + load_resources() + resource_relative_path = ( + (temp_resource_root / path).resolve() + if abs_path + else (temp_resource_root / path) + ) + if only_exist: + if resource_relative_path.exists(): + return resource_relative_path + else: + return default + else: + return resource_relative_path + + +def get_files( + path: os.PathLike[str,] | Path | str, abs_path: bool = False +) -> list[str]: + """ + 获取资源包中一个目录的所有内容 + Args: + path: 该目录的相对路径 + abs_path: 是否返回绝对路径 + Returns: 目录内容路径所构成之列表 + """ + resource_relative_path = temp_resource_root / path + if resource_relative_path.exists(): + return [ + ( + str((resource_relative_path / file_).resolve()) + if abs_path + else str((resource_relative_path / file_)) + ) + for file_ in os.listdir(resource_relative_path) + ] + else: + return [] + + +def get_resource_files( + path: os.PathLike[str,] | Path | str, abs_path: bool = False +) -> list[Path]: + """ + 获取资源包中一个目录的所有内容 + Args: + path: 该目录的相对路径 + abs_path: 是否返回绝对路径 + Returns: 目录内容路径所构成之列表 + """ + resource_relative_path = temp_resource_root / path + if resource_relative_path.exists(): + return [ + ( + (resource_relative_path / file_).resolve() + if abs_path + else (resource_relative_path / file_) + ) + for file_ in os.listdir(resource_relative_path) + ] else: return [] @@ -150,7 +231,9 @@ def load_resources(): if not os.path.exists("resources/index.json"): json.dump([], open("resources/index.json", "w", encoding="utf-8")) - resource_index: list[str] = json.load(open("resources/index.json", "r", encoding="utf-8")) + resource_index: list[str] = json.load( + open("resources/index.json", "r", encoding="utf-8") + ) resource_index.reverse() # 优先级高的后加载,但是排在前面 for resource in resource_index: load_resource_from_dir(os.path.join("resources", resource)) @@ -174,7 +257,9 @@ def check_exist(name: str) -> bool: Returns: 是否存在 """ path = os.path.join("resources", name) - return os.path.exists(os.path.join(path, "metadata.yml")) or (os.path.isfile(path) and name.endswith(".zip")) + return os.path.exists(os.path.join(path, "metadata.yml")) or ( + os.path.isfile(path) and name.endswith(".zip") + ) def add_resource_pack(name: str) -> bool: @@ -185,17 +270,19 @@ def add_resource_pack(name: str) -> bool: Returns: """ if check_exist(name): - old_index: list[str] = json.load(open("resources/index.json", "r", encoding="utf-8")) + old_index: list[str] = json.load( + open("resources/index.json", "r", encoding="utf-8") + ) if name not in old_index: old_index.append(name) json.dump(old_index, open("resources/index.json", "w", encoding="utf-8")) load_resource_from_dir(os.path.join("resources", name)) return True else: - nonebot.logger.warning(lang.get("liteyuki.resource_loaded", name=name)) + nonebot.logger.warning("资源包 {} 已存在,无需添加".format(name)) return False else: - nonebot.logger.warning(lang.get("liteyuki.resource_not_exist", name=name)) + nonebot.logger.warning("资源包 {} 不存在,无法添加".format(name)) return False @@ -207,16 +294,18 @@ def remove_resource_pack(name: str) -> bool: Returns: """ if check_exist(name): - old_index: list[str] = json.load(open("resources/index.json", "r", encoding="utf-8")) + old_index: list[str] = json.load( + open("resources/index.json", "r", encoding="utf-8") + ) if name in old_index: old_index.remove(name) json.dump(old_index, open("resources/index.json", "w", encoding="utf-8")) return True else: - nonebot.logger.warning(lang.get("liteyuki.resource_not_loaded", name=name)) + nonebot.logger.warning("资源包 {} 不存在,无需移除".format(name)) return False else: - nonebot.logger.warning(lang.get("liteyuki.resource_not_exist", name=name)) + nonebot.logger.warning("资源包 {} 不存在,无法移除".format(name)) return False @@ -229,7 +318,9 @@ def change_priority(name: str, delta: int) -> bool: Returns: """ # 正数表示前移,负数表示后移 - old_resource_list: list[str] = json.load(open("resources/index.json", "r", encoding="utf-8")) + old_resource_list: list[str] = json.load( + open("resources/index.json", "r", encoding="utf-8") + ) new_resource_list = old_resource_list.copy() if name in old_resource_list: index = old_resource_list.index(name) @@ -237,13 +328,15 @@ def change_priority(name: str, delta: int) -> bool: new_index = index + delta new_resource_list.remove(name) new_resource_list.insert(new_index, name) - json.dump(new_resource_list, open("resources/index.json", "w", encoding="utf-8")) + json.dump( + new_resource_list, open("resources/index.json", "w", encoding="utf-8") + ) return True else: - nonebot.logger.warning("Priority change failed, out of range") + nonebot.logger.warning("无法更改优先级为 {} ,优先级超出范围".format(delta)) return False else: - nonebot.logger.debug("Priority change failed, resource not loaded") + nonebot.logger.debug("资源包 {} 未加载,无法更改优先级".format(name)) return False diff --git a/src/utils/event/get_info.py b/src/utils/event/get_info.py index 218b6e2..d2d3607 100644 --- a/src/utils/event/get_info.py +++ b/src/utils/event/get_info.py @@ -1,6 +1,6 @@ from nonebot.adapters import satori - -from src.utils.base.ly_typing import T_MessageEvent +from nonebot.adapters import onebot +from src.utils.base.ly_typing import T_MessageEvent, T_GroupMessageEvent def get_user_id(event: T_MessageEvent): @@ -10,11 +10,13 @@ def get_user_id(event: T_MessageEvent): return event.user_id -def get_group_id(event: T_MessageEvent): +def get_group_id(event: T_GroupMessageEvent): if isinstance(event, satori.event.Event): return event.guild.id - else: + elif isinstance(event, onebot.v11.GroupMessageEvent): return event.group_id + else: + return None def get_message_type(event: T_MessageEvent) -> str: diff --git a/src/utils/message/html_tool.py b/src/utils/message/html_tool.py index 8f0cb62..9f43824 100644 --- a/src/utils/message/html_tool.py +++ b/src/utils/message/html_tool.py @@ -1,140 +1,20 @@ -import os.path - -# import time -from os import getcwd - +import os import aiofiles import nonebot -from nonebot_plugin_htmlrender import * # type: ignore +from nonebot import require + +require("nonebot_plugin_htmlrender") + +from nonebot_plugin_htmlrender import ( + template_to_html, + template_to_pic, + md_to_pic, + init, +) + from .tools import random_hex_string -# import imgkit -# from typing import Any, Dict, Literal, Optional, Union -# import uuid - -# import jinja2 -# from pathlib import Path - -# TEMPLATES_PATH = str(Path(__file__).parent / "templates") -# env = jinja2.Environment( # noqa: S701 -# extensions=["jinja2.ext.loopcontrols"], -# loader=jinja2.FileSystemLoader(TEMPLATES_PATH), -# enable_async=True, -# ) - - -# async def template_to_html( -# template_path: str, -# template_name: str, -# **kwargs, -# ) -> str: -# """使用jinja2模板引擎通过html生成图片 - -# Args: -# template_path (str): 模板路径 -# template_name (str): 模板名 -# **kwargs: 模板内容 -# Returns: -# str: html -# """ - -# template_env = jinja2.Environment( # noqa: S701 -# loader=jinja2.FileSystemLoader(template_path), -# enable_async=True, -# ) -# template = template_env.get_template(template_name) - -# return await template.render_async(**kwargs) - - -# async def template_to_pic( -# template_path: str, -# template_name: str, -# templates: Dict[Any, Any], -# pages: Optional[Dict[Any, Any]] = None, -# wait: int = 0, -# type: Literal["jpeg", "png"] = "png", # noqa: A002 -# quality: Union[int, None] = None, -# device_scale_factor: float = 2, -# ) -> bytes: -# """使用jinja2模板引擎通过html生成图片 - -# Args: -# template_path (str): 模板路径 -# template_name (str): 模板名 -# templates (Dict[Any, Any]): 模板内参数 如: {"name": "abc"} -# pages (Optional[Dict[Any, Any]]): 网页参数 Defaults to -# {"base_url": f"file://{getcwd()}", "viewport": {"width": 500, "height": 10}} -# wait (int, optional): 网页载入等待时间. Defaults to 0. -# type (Literal["jpeg", "png"]): 图片类型, 默认 png -# quality (int, optional): 图片质量 0-100 当为`png`时无效 -# device_scale_factor: 缩放比例,类型为float,值越大越清晰(真正想让图片清晰更优先请调整此选项) -# Returns: -# bytes: 图片 可直接发送 -# """ -# if pages is None: -# pages = { -# "viewport": {"width": 500, "height": 10}, -# "base_url": f"file://{getcwd()}", # noqa: PTH109 -# } - -# template_env = jinja2.Environment( # noqa: S701 -# loader=jinja2.FileSystemLoader(template_path), -# enable_async=True, -# ) -# template = template_env.get_template(template_name) - -# open( -# filename := os.path.join( -# template_path, -# str(uuid.uuid4())+".html", -# ), -# "w", -# ).write(await template.render_async(**templates)) - -# print(pages,filename) - - -# img = imgkit.from_file( -# filename, -# output_path=False, -# options={ -# "format": type, -# "quality": quality if (quality and type == "jpeg") else 94, -# "allow": pages["base_url"], -# # "viewport-size": "{} {}".format(pages["viewport"]["width"],pages["viewport"]["height"]), -# "zoom": device_scale_factor, -# # "load-error-handling": "ignore", -# "enable-local-file-access": None, -# "no-stop-slow-scripts": None, -# "transparent": None, -# }, -# ) # type: ignore - - -# # os.remove(filename) - -# return img - -# return await html_to_pic( -# template_path=f"file://{template_path}", -# html=await template.render_async(**templates), -# wait=wait, -# type=type, -# quality=quality, -# device_scale_factor=device_scale_factor, -# **pages, -# ) - - -async def html2image( - html: str, - wait: int = 0, -): - pass - - async def template2html( template: str, templates: dict, @@ -174,8 +54,8 @@ async def template2image( if pages is None: pages = { "viewport": {"width": 1080, "height": 10}, - "base_url": f"file://{getcwd()}", } + template_path = os.path.dirname(template) template_name = os.path.basename(template) @@ -197,36 +77,9 @@ async def template2image( template_name=template_name, template_path=template_path, templates=templates, - pages=pages, wait=wait, + ### + pages=pages, device_scale_factor=scale_factor, + ### ) - - -# async def url2image( -# url: str, -# wait: int = 0, -# scale_factor: float = 1, -# type: str = "png", -# quality: int = 100, -# **kwargs -# ) -> bytes: -# """ -# Args: -# quality: -# type: -# url: str: URL -# wait: int: 等待时间 -# scale_factor: float: 缩放因子 -# **kwargs: page 参数 -# Returns: -# 图片二进制数据 -# """ -# async with get_new_page(scale_factor) as page: -# await page.goto(url) -# await page.wait_for_timeout(wait) -# return await page.screenshot( -# full_page=True, -# type=type, -# quality=quality -# ) diff --git a/src/utils/message/message.py b/src/utils/message/message.py index ca4bd70..100677d 100644 --- a/src/utils/message/message.py +++ b/src/utils/message/message.py @@ -1,33 +1,20 @@ import base64 import io +from typing import Any from urllib.parse import quote import aiofiles -from PIL import Image import aiohttp import nonebot -from nonebot import require -from nonebot.adapters import satori +from PIL import Image from nonebot.adapters.onebot import v11 -from typing import Any, Type - -from nonebot.internal.adapter import MessageSegment -from nonebot.internal.adapter.message import TM +from .html_tool import md_to_pic from .. import load_from_yaml from ..base.ly_typing import T_Bot, T_Message, T_MessageEvent -require("nonebot_plugin_htmlrender") -from nonebot_plugin_htmlrender import md_to_pic - config = load_from_yaml("config.yml") -can_send_markdown = {} # 用于存储机器人是否支持发送markdown消息,id->bool - - -class TencentBannedMarkdownError(BaseException): - pass - async def broadcast_to_superusers(message: str | T_Message, markdown: bool = False): """广播消息给超级用户""" @@ -49,9 +36,6 @@ class MarkdownMessage: *, message_type: str = None, session_id: str | int = None, - event: T_MessageEvent = None, - retry_as_image: bool = True, - **kwargs, ) -> dict[str, Any] | None: """ 发送Markdown消息,支持自动转为图片发送 @@ -60,86 +44,20 @@ class MarkdownMessage: bot: message_type: session_id: - event: - retry_as_image: 发送失败后是否尝试以图片形式发送,否则失败返回None - **kwargs: - Returns: """ - formatted_md = v11.unescape(markdown).replace("\n", r"\n").replace('"', r"\\\"") - if event is not None and message_type is None: - if isinstance(event, satori.event.Event): - message_type = "private" if event.guild is None else "group" - group_id = event.guild.id if event.guild is not None else None - else: - assert event is not None - message_type = event.message_type - group_id = event.group_id if message_type == "group" else None - user_id = ( - event.user.id - if isinstance(event, satori.event.Event) - else event.user_id - ) - session_id = user_id if message_type == "private" else group_id - - # try: - # raise TencentBannedMarkdownError("Tencent banned markdown") - # forward_id = await bot.call_api( - # "send_private_forward_msg", - # messages=[ - # { - # "type": "node", - # "data": { - # "content": [ - # { - # "data": { - # "content": "{\"content\":\"%s\"}" % formatted_md, - # }, - # "type": "markdown" - # } - # ], - # "name": "[]", - # "uin": bot.self_id - # } - # } - # ], - # user_id=bot.self_id - - # ) - # data = await bot.send_msg( - # user_id=session_id, - # group_id=session_id, - # message_type=message_type, - # message=[ - # { - # "type": "longmsg", - # "data": { - # "id": forward_id - # } - # }, - # ], - # **kwargs - # ) - # except BaseException as e: - - nonebot.logger.error(f"因未能发送Markdown消息,已转为图片发送。") - # 发送失败,渲染为图片发送 - # if not retry_as_image: - # return None - - # plain_markdown = markdown.replace("[🔗", "[") - md_image_bytes = await md_to_pic(md=markdown, width=540, device_scale_factor=4) - if isinstance(bot, satori.Bot): - msg_seg = satori.MessageSegment.image(raw=md_image_bytes, mime="image/png") - data = await bot.send(event=event, message=msg_seg) - else: - data = await bot.send_msg( - message_type=message_type, - group_id=session_id, - user_id=session_id, - message=v11.MessageSegment.image(md_image_bytes), - ) + plain_markdown = markdown.replace("[🔗", "[") + md_image_bytes = await md_to_pic( + md=plain_markdown, width=540, device_scale_factor=4 + ) + print(md_image_bytes) + data = await bot.send_msg( + message_type=message_type, + group_id=session_id, + user_id=session_id, + message=v11.MessageSegment.image(md_image_bytes), + ) return data @staticmethod @@ -157,38 +75,25 @@ class MarkdownMessage: Args: image: 图片字节流或图片本地路径,链接请使用Markdown.image_async方法获取后通过send_md发送 bot: bot instance - message_type: message type + message_type: message message_type session_id: session id event: event kwargs: other arguments Returns: dict: response data - """ if isinstance(image, str): async with aiofiles.open(image, "rb") as f: image = await f.read() method = 2 - # 1.轻雪图床方案 - # if method == 1: - # image_url = await liteyuki_api.upload_image(image) - # image_size = Image.open(io.BytesIO(image)).size - # image_md = Markdown.image(image_url, image_size) - # data = await Markdown.send_md(image_md, bot, message_type=message_type, session_id=session_id, event=event, - # retry_as_image=False, - # **kwargs) - - # Lagrange.OneBot方案 if method == 2: base64_string = base64.b64encode(image).decode("utf-8") data = await bot.call_api("upload_image", file=f"base64://{base64_string}") await MarkdownMessage.send_md( MarkdownMessage.image(data, Image.open(io.BytesIO(image)).size), bot, - event=event, message_type=message_type, session_id=session_id, - **kwargs, ) # 其他实现端方案 @@ -204,12 +109,7 @@ class MarkdownMessage: image_size = Image.open(io.BytesIO(image)).size image_md = MarkdownMessage.image(image_url, image_size) return await MarkdownMessage.send_md( - image_md, - bot, - message_type=message_type, - session_id=session_id, - event=event, - **kwargs, + image_md, bot, message_type=message_type, session_id=session_id ) if data is None: @@ -293,7 +193,7 @@ class MarkdownMessage: image = Image.open(io.BytesIO(await resp.read())) return MarkdownMessage.image(url, image.size) except Exception as e: - nonebot.logger.error(f"get image error: {e}") + nonebot.logger.error(f"获取图片错误:{e}") return "[Image Error]" @staticmethod diff --git a/tests/test_config_load.py b/tests/test_config_load.py new file mode 100644 index 0000000..d4c3039 --- /dev/null +++ b/tests/test_config_load.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/11 下午11:07 +@Author : snowykami +@Email : snowykami@outlook.com +@File : test_config_load.py +@Software: PyCharm +""" +import json +import os +import sys + +sys.path.insert(0, os.getcwd()) +from liteyuki.config import load_config_in_default + + +def test_default_load(): + config = load_config_in_default() + print(json.dumps(config, indent=4, ensure_ascii=False)) diff --git a/tests/test_core.py b/tests/test_core.py index 81da20e..b8983b0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,4 @@ -from src.liteyuki import LiteyukiBot +from liteyuki import LiteyukiBot if __name__ == "__main__": lyb = LiteyukiBot() diff --git a/tests/test_dll.py b/tests/test_dll.py deleted file mode 100644 index 8d44378..0000000 --- a/tests/test_dll.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved - -@Time : 2024/8/7 下午11:44 -@Author : snowykami -@Email : snowykami@outlook.com -@File : test_dll.py -@Software: PyCharm -""" -from src.utils.extension import load_lib - - -a = load_lib("src/libs/ly_api") - -a.Register("sss", "sss", 64, "sss", "sss") \ No newline at end of file diff --git a/unused_resource/liteyuki_weather/templates/js/weather_now.js b/unused_resource/liteyuki_weather/templates/js/weather_now.js index b39552f..5fc41c0 100644 --- a/unused_resource/liteyuki_weather/templates/js/weather_now.js +++ b/unused_resource/liteyuki_weather/templates/js/weather_now.js @@ -180,4 +180,4 @@ function get_time_hour(fxTime) { let attrinfo = document.getElementById('attribution-info'); -attrinfo.innerText = is_dev ? "Weather Service Drived by QWeather" : (attr ? attr : "Weather Service Drived by QWeather"); +attrinfo.innerText = is_dev ? "Weather service by QWeather" : (attr ? attr : "Weather Service Drived by QWeather");