diff --git a/liteyuki/__init__.py b/liteyuki/__init__.py new file mode 100644 index 00000000..c97c2715 --- /dev/null +++ b/liteyuki/__init__.py @@ -0,0 +1,12 @@ +from liteyuki.bot import ( + LiteyukiBot, + get_bot +) + +# def get_bot_instance() -> LiteyukiBot | None: +# """ +# 获取轻雪实例 +# Returns: +# LiteyukiBot: 当前的轻雪实例 +# """ +# return _BOT_INSTANCE diff --git a/liteyuki/bot/__init__.py b/liteyuki/bot/__init__.py new file mode 100644 index 00000000..889ada0a --- /dev/null +++ b/liteyuki/bot/__init__.py @@ -0,0 +1,243 @@ +import asyncio +import multiprocessing +from typing import Any, Coroutine, Optional + +import nonebot + +from liteyuki.plugin.load import load_plugin, load_plugins +from src.utils import ( + adapter_manager, + driver_manager, +) +from src.utils.base.log import logger +from liteyuki.bot.lifespan import ( + Lifespan, + LIFESPAN_FUNC, +) +from liteyuki.core.spawn_process import nb_run, ProcessingManager + +__all__ = [ + "LiteyukiBot", + "get_bot" +] + +_MAIN_PROCESS = multiprocessing.current_process().name == "MainProcess" + + +class LiteyukiBot: + def __init__(self, *args, **kwargs): + + global _BOT_INSTANCE + _BOT_INSTANCE = self # 引用 + self.running = False + self.config: dict[str, Any] = kwargs + self.lifespan: Lifespan = Lifespan() + self.init(**self.config) # 初始化 + + if not _MAIN_PROCESS: + pass + else: + print("\033[34m" + r""" + __ ______ ________ ________ __ __ __ __ __ __ ______ +/ | / |/ |/ |/ \ / |/ | / |/ | / |/ | +$$ | $$$$$$/ $$$$$$$$/ $$$$$$$$/ $$ \ /$$/ $$ | $$ |$$ | /$$/ $$$$$$/ +$$ | $$ | $$ | $$ |__ $$ \/$$/ $$ | $$ |$$ |/$$/ $$ | +$$ | $$ | $$ | $$ | $$ $$/ $$ | $$ |$$ $$< $$ | +$$ | $$ | $$ | $$$$$/ $$$$/ $$ | $$ |$$$$$ \ $$ | +$$ |_____ _$$ |_ $$ | $$ |_____ $$ | $$ \__$$ |$$ |$$ \ _$$ |_ +$$ |/ $$ | $$ | $$ | $$ | $$ $$/ $$ | $$ |/ $$ | +$$$$$$$$/ $$$$$$/ $$/ $$$$$$$$/ $$/ $$$$$$/ $$/ $$/ $$$$$$/ + """ + "\033[0m") + + def run(self, *args, **kwargs): + + if _MAIN_PROCESS: + load_plugins("liteyuki/plugins") + asyncio.run(self.lifespan.before_start()) + self._run_nb_in_spawn_process(*args, **kwargs) + else: + # 子进程启动 + + driver_manager.init(config=self.config) + adapter_manager.init(self.config) + adapter_manager.register() + nonebot.load_plugin("src.liteyuki_main") + + def _run_nb_in_spawn_process(self, *args, **kwargs): + """ + 在新的进程中运行nonebot.run方法 + Args: + *args: + **kwargs: + + Returns: + """ + + timeout_limit: int = 20 + should_exit = False + + while not should_exit: + ctx = multiprocessing.get_context("spawn") + event = ctx.Event() + ProcessingManager.event = event + process = ctx.Process( + target=nb_run, + args=(event,) + args, + kwargs=kwargs, + ) + process.start() # 启动进程 + + asyncio.run(self.lifespan.after_start()) + + while not should_exit: + if ProcessingManager.event.wait(1): + logger.info("Receive reboot event") + process.terminate() + process.join(timeout_limit) + if process.is_alive(): + logger.warning( + f"Process {process.pid} is still alive after {timeout_limit} seconds, force kill it." + ) + process.kill() + break + elif process.is_alive(): + continue + else: + should_exit = True + + @property + def status(self) -> int: + """ + 获取轻雪状态 + Returns: + int: 0:未启动 1:运行中 + """ + return 1 if self.running else 0 + + def restart(self): + """ + 停止轻雪 + Returns: + + """ + logger.info("Stopping LiteyukiBot...") + + logger.debug("Running before_restart functions...") + asyncio.run(self.lifespan.before_restart()) + logger.debug("Running before_shutdown functions...") + asyncio.run(self.lifespan.before_shutdown()) + + ProcessingManager.restart() + self.running = False + + def init(self, *args, **kwargs): + """ + 初始化轻雪, 自动调用 + Returns: + + """ + self.init_config() + self.init_logger() + if not _MAIN_PROCESS: + nonebot.init(**kwargs) + asyncio.run(self.lifespan.after_nonebot_init()) + + def init_logger(self): + from src.utils.base.log import init_log + init_log() + + def init_config(self): + pass + + def register_adapters(self, *args): + pass + + def on_before_start(self, func: LIFESPAN_FUNC): + """ + 注册启动前的函数 + Args: + func: + + Returns: + + """ + return self.lifespan.on_before_start(func) + + def on_after_start(self, func: LIFESPAN_FUNC): + """ + 注册启动后的函数 + Args: + func: + + Returns: + + """ + 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: + + Returns: + + """ + return self.lifespan.on_after_shutdown(func) + + def on_before_restart(self, func: LIFESPAN_FUNC): + """ + 注册重启前的函数 + Args: + func: + + Returns: + + """ + + return self.lifespan.on_before_restart(func) + + def on_after_restart(self, func: LIFESPAN_FUNC): + """ + 注册重启后的函数:未实现 + Args: + func: + + Returns: + + """ + 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: Optional[LiteyukiBot] = None + + +def get_bot() -> Optional[LiteyukiBot]: + """ + 获取轻雪实例 + Returns: + LiteyukiBot: 当前的轻雪实例 + """ + return _BOT_INSTANCE diff --git a/liteyuki/bot/lifespan.py b/liteyuki/bot/lifespan.py new file mode 100644 index 00000000..eec69492 --- /dev/null +++ b/liteyuki/bot/lifespan.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/7/23 下午8:24 +@Author : snowykami +@Email : snowykami@outlook.com +@File : lifespan.py +@Software: PyCharm +""" +from typing import Any, Awaitable, Callable, TypeAlias + +from liteyuki.utils import is_coroutine_callable + +SYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Any] +ASYNC_LIFESPAN_FUNC: TypeAlias = Callable[[], Awaitable[Any]] +LIFESPAN_FUNC: TypeAlias = SYNC_LIFESPAN_FUNC | ASYNC_LIFESPAN_FUNC + + +class Lifespan: + def __init__(self) -> None: + """ + 轻雪生命周期管理,启动、停止、重启 + """ + + self.life_flag: int = 0 # 0: 启动前,1: 启动后,2: 停止前,3: 停止后 + + self._before_start_funcs: list[LIFESPAN_FUNC] = [] + self._after_start_funcs: list[LIFESPAN_FUNC] = [] + + self._before_shutdown_funcs: list[LIFESPAN_FUNC] = [] + self._after_shutdown_funcs: list[LIFESPAN_FUNC] = [] + + self._before_restart_funcs: list[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: + """ + 运行函数 + Args: + funcs: + Returns: + """ + for func in funcs: + if is_coroutine_callable(func): + await func() + else: + func() + + def on_before_start(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册启动时的函数 + Args: + func: + Returns: + LIFESPAN_FUNC: + """ + self._before_start_funcs.append(func) + return func + + def on_after_start(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册启动时的函数 + Args: + func: + Returns: + LIFESPAN_FUNC: + """ + self._after_start_funcs.append(func) + return func + + def on_before_shutdown(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册停止前的函数 + Args: + func: + Returns: + LIFESPAN_FUNC: + """ + self._before_shutdown_funcs.append(func) + return func + + def on_after_shutdown(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册停止后的函数 + Args: + func: + + Returns: + LIFESPAN_FUNC: + + """ + self._after_shutdown_funcs.append(func) + return func + + def on_before_restart(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册重启时的函数 + Args: + func: + Returns: + LIFESPAN_FUNC: + """ + self._before_restart_funcs.append(func) + return func + + def on_after_restart(self, func: LIFESPAN_FUNC) -> LIFESPAN_FUNC: + """ + 注册重启后的函数 + Args: + func: + Returns: + 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: + """ + await self._run_funcs(self._before_start_funcs) + + async def after_start(self) -> None: + """ + 启动后 + Returns: + """ + await self._run_funcs(self._after_start_funcs) + + async def before_shutdown(self) -> None: + """ + 停止前 + Returns: + """ + await self._run_funcs(self._before_shutdown_funcs) + + async def after_shutdown(self) -> None: + """ + 停止后 + Returns: + """ + await self._run_funcs(self._after_shutdown_funcs) + + async def before_restart(self) -> None: + """ + 重启前 + Returns: + """ + await self._run_funcs(self._before_restart_funcs) + + async def after_restart(self) -> None: + """ + 重启后 + Returns: + + """ + await self._run_funcs(self._after_restart_funcs) + + async def after_nonebot_init(self) -> None: + """ + NoneBot 初始化后 + Returns: + """ + await self._run_funcs(self._after_nonebot_init_funcs) diff --git a/src/liteyuki/__init__.py b/liteyuki/config.py similarity index 100% rename from src/liteyuki/__init__.py rename to liteyuki/config.py diff --git a/liteyuki/core/__init__.py b/liteyuki/core/__init__.py new file mode 100644 index 00000000..e8f45b2a --- /dev/null +++ b/liteyuki/core/__init__.py @@ -0,0 +1,3 @@ +from .spawn_process import * + + diff --git a/liteyuki/core/spawn_process.py b/liteyuki/core/spawn_process.py new file mode 100644 index 00000000..fb56e0fe --- /dev/null +++ b/liteyuki/core/spawn_process.py @@ -0,0 +1,37 @@ +import threading +from multiprocessing import get_context, Event + +import nonebot +from nonebot import logger + +from liteyuki.plugin.load import load_plugins + +timeout_limit: int = 20 +__all__ = [ + "ProcessingManager", + "nb_run", +] + + +class ProcessingManager: + event: Event = None + + @classmethod + def restart(cls, delay: int = 0): + """ + 发送终止信号 + Args: + delay: 延迟时间,默认为0,单位秒 + Returns: + """ + if cls.event is None: + raise RuntimeError("ProcessingManager has not been initialized.") + if delay > 0: + threading.Timer(delay, function=cls.event.set).start() + return + cls.event.set() + + +def nb_run(event, *args, **kwargs): + ProcessingManager.event = event + nonebot.run(*args, **kwargs) diff --git a/src/liteyuki/exception.py b/liteyuki/exception.py similarity index 100% rename from src/liteyuki/exception.py rename to liteyuki/exception.py diff --git a/liteyuki/plugin/__init__.py b/liteyuki/plugin/__init__.py new file mode 100644 index 00000000..485f28bd --- /dev/null +++ b/liteyuki/plugin/__init__.py @@ -0,0 +1,17 @@ +from liteyuki.plugin.model import Plugin, PluginMetadata +from liteyuki.plugin.load import load_plugin, _plugins + +__all__ = [ + "PluginMetadata", + "Plugin", + "load_plugin", +] + + +def get_loaded_plugins() -> dict[str, Plugin]: + """ + 获取已加载的插件 + Returns: + dict[str, Plugin]: 插件字典 + """ + return _plugins diff --git a/liteyuki/plugin/load.py b/liteyuki/plugin/load.py new file mode 100644 index 00000000..a3cdba97 --- /dev/null +++ b/liteyuki/plugin/load.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/7/23 下午11:59 +@Author : snowykami +@Email : snowykami@outlook.com +@File : load.py +@Software: PyCharm +""" +import os +import traceback +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.utils import path_to_module_name + +_plugins: dict[str, Plugin] = {} + + +def load_plugin(module_path: str | Path) -> Optional[Plugin]: + """加载单个插件,可以是本地插件或是通过 `pip` 安装的插件。 + + 参数: + 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 + 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) + ) + logger.opt(colors=True).success( + f'Succeeded to load liteyuki plugin "{module.__name__.split(".")[-1]}"' + ) + return _plugins[module.__name__] + + except Exception as e: + logger.opt(colors=True).success( + f'Failed to load liteyuki plugin "{module_path}"' + ) + traceback.print_exc() + return None + + +def load_plugins(*plugin_dir: str) -> set[Plugin]: + """导入文件夹下多个插件 + + 参数: + plugin_dir: 文件夹路径 + """ + plugins = set() + for dir_path in plugin_dir: + # 遍历每一个文件夹下的py文件和包含__init__.py的文件夹,不递归 + 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': + 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')): + module_name = path_to_module_name(path) + + if module_name: + load_plugin(module_name) + if _plugins.get(module_name): + plugins.add(_plugins[module_name]) + return plugins diff --git a/liteyuki/plugin/manager.py b/liteyuki/plugin/manager.py new file mode 100644 index 00000000..91077759 --- /dev/null +++ b/liteyuki/plugin/manager.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/7/23 下午11:59 +@Author : snowykami +@Email : snowykami@outlook.com +@File : manager.py +@Software: PyCharm +""" diff --git a/liteyuki/plugin/model.py b/liteyuki/plugin/model.py new file mode 100644 index 00000000..70f8dab6 --- /dev/null +++ b/liteyuki/plugin/model.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/7/24 上午12:02 +@Author : snowykami +@Email : snowykami@outlook.com +@File : model.py +@Software: PyCharm +""" +from types import ModuleType +from typing import Optional + +from pydantic import BaseModel + + +class PluginMetadata(BaseModel): + """ + 轻雪插件元数据,由插件编写者提供 + """ + name: str + description: str + usage: str = "" + type: str = "" + homepage: str = "" + running_in_main: bool = True # 是否在主进程运行 + + +class Plugin(BaseModel): + """ + 存储插件信息 + """ + model_config = { + 'arbitrary_types_allowed': True + } + name: str + """插件名称 例如plugin_loader""" + module: ModuleType + """插件模块对象""" + module_name: str + """点分割模块路径 例如a.b.c""" + metadata: Optional[PluginMetadata] = None + + def __hash__(self): + return hash(self.module_name) diff --git a/liteyuki/plugins/plugin_loader/__init__.py b/liteyuki/plugins/plugin_loader/__init__.py new file mode 100644 index 00000000..507631a0 --- /dev/null +++ b/liteyuki/plugins/plugin_loader/__init__.py @@ -0,0 +1,33 @@ +import multiprocessing + +import nonebot +from nonebot import get_driver + +from liteyuki.plugin import PluginMetadata +from liteyuki import get_bot + +__plugin_metadata__ = PluginMetadata( + name="plugin_loader", + description="轻雪插件加载器", + usage="", + type="", + homepage="" +) + +liteyuki = get_bot() + + +@liteyuki.on_after_start +def _(): + print("轻雪启动完成,运行在进程", multiprocessing.current_process().name) + + +@liteyuki.on_before_start +def _(): + print("轻雪启动中") + + +@liteyuki.on_after_nonebot_init +async def _(): + print("NoneBot初始化完成") + nonebot.load_plugin("src.liteyuki_main") diff --git a/liteyuki/plugins/plugin_loader/data_source.py b/liteyuki/plugins/plugin_loader/data_source.py new file mode 100644 index 00000000..e744bd8c --- /dev/null +++ b/liteyuki/plugins/plugin_loader/data_source.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/7/23 下午11:21 +@Author : snowykami +@Email : snowykami@outlook.com +@File : data_source.py +@Software: PyCharm +""" diff --git a/src/liteyuki/core/__init__.py b/liteyuki/plugins/process_manager/__init__.py similarity index 100% rename from src/liteyuki/core/__init__.py rename to liteyuki/plugins/process_manager/__init__.py diff --git a/src/liteyuki/plugin/__init__.py b/liteyuki/plugins/resource_loader/__init__.py similarity index 100% rename from src/liteyuki/plugin/__init__.py rename to liteyuki/plugins/resource_loader/__init__.py diff --git a/liteyuki/utils.py b/liteyuki/utils.py new file mode 100644 index 00000000..af95af97 --- /dev/null +++ b/liteyuki/utils.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" +一些常用的工具类,部分来源于 nonebot 并遵循其许可进行修改 +""" +import inspect +from pathlib import Path +from typing import Any, Callable + + +def is_coroutine_callable(call: Callable[..., Any]) -> bool: + """ + 判断是否为协程可调用对象 + Args: + call: 可调用对象 + Returns: + bool: 是否为协程可调用对象 + """ + if inspect.isroutine(call): + return inspect.iscoroutinefunction(call) + if inspect.isclass(call): + return False + func_ = getattr(call, "__call__", None) + return inspect.iscoroutinefunction(func_) + + +def path_to_module_name(path: Path) -> str: + """ + 转换路径为模块名 + Args: + path: 路径a/b/c/d -> a.b.c.d + Returns: + str: 模块名 + """ + rel_path = path.resolve().relative_to(Path.cwd().resolve()) + if rel_path.stem == "__init__": + return ".".join(rel_path.parts[:-1]) + else: + return ".".join(rel_path.parts[:-1] + (rel_path.stem,)) diff --git a/main.py b/main.py index 23b557bf..44eb1a29 100644 --- a/main.py +++ b/main.py @@ -1,28 +1,6 @@ -import nonebot -from src.utils import adapter_manager, driver_manager, init -from src.utils.base.config import load_from_yaml -from src.utils.base.data_manager import StoredConfig, common_db -from src.utils.base.ly_api import liteyuki_api +from liteyuki import LiteyukiBot +from src.utils import load_from_yaml -if __name__ == "__mp_main__": - # Start as multiprocessing - init() - store_config: dict = common_db.where_one(StoredConfig(), default=StoredConfig()).config - static_config = load_from_yaml("config.yml") - store_config.update(static_config) - driver_manager.init(config=store_config) - adapter_manager.init(store_config) - nonebot.init(**store_config) - adapter_manager.register() - try: - nonebot.load_plugin("src.liteyuki_main") - nonebot.load_from_toml("pyproject.toml") - except BaseException as e: - if not isinstance(e, KeyboardInterrupt): - nonebot.logger.error(f"An error occurred: {e}, Bug will be reported automatically.") - liteyuki_api.bug_report(str(e.__repr__())) - -if __name__ == "__main__": - # Start as __main__ - from src.utils.base.reloader import Reloader - nonebot.run() +if __name__ in ("__main__", "__mp_main__"): + bot = LiteyukiBot(**load_from_yaml("config.yml")) + bot.run() diff --git a/pyproject.toml b/pyproject.toml index 9d99770f..6af684ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,58 @@ -[tool.nonebot] -[project] # PEP 621 project metadata # See https://www.python.org/dev/peps/pep-0621/ -authors = [ - {name = "SnowyKami", email = "snowykami@outlook.com"}, -] -license = {text = "MIT & LSO"} -requires-python = ">=3.10,<4.0" -dependencies = [ - -] - -dynamic = ["version"] +# This file is for project use, but don`t use with nb-cli +# 此文件为项目所用,请不要和nb-cli一起使用以防被修改 +[tool.poetry] name = "liteyuki-bot" -description = "Push dynamics and live informations from bilibili to QQ. Based on nonebot2." -readme = "README.md" -keywords = ["nonebot", "nonebot2", "qqbot", "liteyuki", "bot"] +version = "6" +description = "based on nonebot2" +authors = ["Snowykami"] +license = "MIT & LSO" +package-mode = false + + +[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] [project.urls] homepage = "https://bot.liteyuki.icu" repository = "https://github.com/LiteyukiStudio/LiteyukiBot" documentation = "https://bot.liteyuki.icu" -[tool.pdm.dev-dependencies] -dev = [] - -[tool.nonebot] -adapters = [ - { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" } - -] -plugins = ["haruka_bot", "nonebot_plugin_gocqhttp", "nonebot_plugin_guild_patch"] -plugin_dirs = [] -builtin_plugins = [] - -[project.scripts] -ly = "main.py" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4b9766d0..9c95cf22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,14 +18,13 @@ pydantic~=2.7.0 Pygments~=2.17.2 pytz~=2024.1 PyYAML~=6.0.1 +pillow~=10.0.0 starlette~=0.36.3 loguru~=0.7.2 importlib_metadata~=7.0.2 requests~=2.31.0 watchdog~=4.0.0 -pillow~=10.2.0 jieba~=0.42.1 -pip~=23.2.1 aiosqlite3~=0.3.0 fastapi~=0.110.0 python-dotenv~=1.0.1 \ No newline at end of file diff --git a/src/liteyuki/bot/__init__.py b/src/liteyuki/bot/__init__.py deleted file mode 100644 index e8e87441..00000000 --- a/src/liteyuki/bot/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -import abc - - -class Bot(abc.ABC): - def __init__(self): - pass \ No newline at end of file diff --git a/src/liteyuki/plugins/process_manager/__init__.py b/src/liteyuki/plugins/process_manager/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/liteyuki/plugins/resource_loader/__init__.py b/src/liteyuki/plugins/resource_loader/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/liteyuki_main/__init__.py b/src/liteyuki_main/__init__.py index b54fb047..8bd623a3 100644 --- a/src/liteyuki_main/__init__.py +++ b/src/liteyuki_main/__init__.py @@ -18,17 +18,6 @@ __plugin_meta__ = PluginMetadata( from ..utils.base.language import Language, get_default_lang_code -print("\033[34m" + r""" - __ ______ ________ ________ __ __ __ __ __ __ ______ -/ | / |/ |/ |/ \ / |/ | / |/ | / |/ | -$$ | $$$$$$/ $$$$$$$$/ $$$$$$$$/ $$ \ /$$/ $$ | $$ |$$ | /$$/ $$$$$$/ -$$ | $$ | $$ | $$ |__ $$ \/$$/ $$ | $$ |$$ |/$$/ $$ | -$$ | $$ | $$ | $$ | $$ $$/ $$ | $$ |$$ $$< $$ | -$$ | $$ | $$ | $$$$$/ $$$$/ $$ | $$ |$$$$$ \ $$ | -$$ |_____ _$$ |_ $$ | $$ |_____ $$ | $$ \__$$ |$$ |$$ \ _$$ |_ -$$ |/ $$ | $$ | $$ | $$ | $$ $$/ $$ | $$ |/ $$ | -$$$$$$$$/ $$$$$$/ $$/ $$$$$$$$/ $$/ $$$$$$/ $$/ $$/ $$$$$$/ -""" + "\033[0m") sys_lang = Language(get_default_lang_code()) -nonebot.logger.info(sys_lang.get("main.current_language", LANG=sys_lang.get("language.name"))) +nonebot.logger.info(sys_lang.get("main.current_language", LANG=sys_lang.get("language.name"))) \ No newline at end of file diff --git a/src/liteyuki_main/core.py b/src/liteyuki_main/core.py index 9270998c..a0d2a279 100644 --- a/src/liteyuki_main/core.py +++ b/src/liteyuki_main/core.py @@ -16,9 +16,11 @@ from src.utils.base.data_manager import StoredConfig, 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 src.utils.base.reloader import Reloader +# from src.liteyuki.core import Reloader from src.utils import event as event_utils, satori_utils +from liteyuki.core import ProcessingManager from .api import update_liteyuki +from liteyuki.bot import get_bot from ..utils.base.ly_function import get_function require("nonebot_plugin_alconna") @@ -92,7 +94,9 @@ async def _(matcher: Matcher, bot: T_Bot, event: T_MessageEvent): ) common_db.save(temp_data) - Reloader.reload(0) + # Reloader.reload(0) + bot = get_bot() + bot.restart() @on_alconna( @@ -281,7 +285,6 @@ async def _(result: Arparma, bot: T_Bot, event: T_MessageEvent, matcher: Matcher result = str(e) args_show = "\n".join("- %s: %s" % (k, v) for k, v in args_dict.items()) - print(f"API: {api_name}\n\nArgs: \n{args_show}\n\nResult: {result}") await matcher.finish(f"API: {api_name}\n\nArgs: \n{args_show}\n\nResult: {result}") @@ -371,7 +374,7 @@ async def every_day_update(): if result: await broadcast_to_superusers(f"Liteyuki updated: ```\n{logs}\n```") nonebot.logger.info(f"Liteyuki updated: {logs}") - Reloader.reload(5) + ProcessingManager.restart() else: nonebot.logger.info(logs) diff --git a/src/liteyuki_main/dev.py b/src/liteyuki_main/dev.py index 72eb3303..4ed79b43 100644 --- a/src/liteyuki_main/dev.py +++ b/src/liteyuki_main/dev.py @@ -2,24 +2,27 @@ import nonebot from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler +from liteyuki.bot import get_bot from src.utils.base.config import get_config -from src.utils.base.reloader import Reloader +from liteyuki.core import ProcessingManager from src.utils.base.resource import load_resources if get_config("debug", False): + liteyuki_bot = get_bot() + src_directories = ( - "src/liteyuki_main", - "src/plugins", - "src/utils", + "src/liteyuki_main", + "src/plugins", + "src/utils", ) src_excludes_extensions = ( - "pyc", + "pyc", ) res_directories = ( - "src/resources", - "resources", + "src/resources", + "resources", ) nonebot.logger.info("Liteyuki Reload enabled, watching for file changes...") @@ -35,7 +38,7 @@ if get_config("debug", False): src_excludes_extensions) or event.is_directory or "__pycache__" in event.src_path: return nonebot.logger.info(f"{event.src_path} modified, reloading bot...") - Reloader.reload() + liteyuki_bot.restart() class ResourceModifiedHandler(FileSystemEventHandler): diff --git a/src/liteyuki_main/loader.py b/src/liteyuki_main/loader.py index a09393c6..55ebccea 100644 --- a/src/liteyuki_main/loader.py +++ b/src/liteyuki_main/loader.py @@ -6,10 +6,13 @@ 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 + load_resources() init_log() driver = get_driver() +liteyuki_bot = get_bot() @driver.on_startup @@ -29,3 +32,33 @@ async def load_plugins(): nonebot.plugin.load_plugins("plugins") else: nonebot.logger.info("Safe mode is on, no plugin loaded.") + + +@liteyuki_bot.on_before_start +async def _(): + print("启动前") + + +@liteyuki_bot.on_after_start +async def _(): + print("启动后") + + +@liteyuki_bot.on_before_shutdown +async def _(): + print("停止前") + + +@liteyuki_bot.on_after_shutdown +async def _(): + print("停止后") + + +@liteyuki_bot.on_before_restart +async def _(): + print("重启前") + + +@liteyuki_bot.on_after_restart +async def _(): + print("重启后") diff --git a/src/plugins/liteyuki_crt_utils/__init__.py b/src/plugins/liteyuki_crt_utils/__init__.py index 21c80667..fd219f6f 100644 --- a/src/plugins/liteyuki_crt_utils/__init__.py +++ b/src/plugins/liteyuki_crt_utils/__init__.py @@ -1,4 +1,7 @@ +import multiprocessing + from nonebot.plugin import PluginMetadata +from liteyuki.plugin import get_loaded_plugins from .rt_guide import * from .crt_matchers import * @@ -14,3 +17,5 @@ __plugin_meta__ = PluginMetadata( "default_enable": True, } ) + +print("Loaded plugins:", len(get_loaded_plugins())) \ No newline at end of file diff --git a/src/plugins/packmanv2/__init__.py b/src/plugins/packmanv2/__init__.py index 92c34d92..bef8efcb 100644 --- a/src/plugins/packmanv2/__init__.py +++ b/src/plugins/packmanv2/__init__.py @@ -1,4 +1,6 @@ from nonebot.plugin import PluginMetadata +from .npm import * +from .rpm import * __author__ = "snowykami" __plugin_meta__ = PluginMetadata( diff --git a/src/plugins/packmanv2/npm/__init__.py b/src/plugins/packmanv2/npm/__init__.py index e69de29b..22242007 100644 --- a/src/plugins/packmanv2/npm/__init__.py +++ b/src/plugins/packmanv2/npm/__init__.py @@ -0,0 +1,69 @@ +# npm update/upgrade +# npm search +# npm install/uninstall +# npm list +from nonebot import require + +require("nonebot_plugin_alconna") + +from nonebot_plugin_alconna import ( + on_alconna, + Alconna, + Args, + MultiVar, + Subcommand, + Option +) + +"""包管理器alc""" +npm_alc = on_alconna( + aliases={"插件", "nonebot-plugin-manager"}, + command=Alconna( + "npm", + Subcommand( + "list", + Args["page", int, 1]["num", int, 10], + alias={"ls", "列表", "列出"}, + dest="list installed plugins", + help_text="列出已安装插件", + ), + Subcommand( + "search", + Args["keywords", MultiVar(str)], + alias=["s", "搜索"], + dest="search plugins", + help_text="搜索本地商店插件,需自行更新", + ), + Subcommand( + "install", + Args["package_name", str], + alias=["i", "安装"], + dest="install plugin", + help_text="安装插件", + ), + Subcommand( + "uninstall", + Args["package_name", str], + alias=["u", "卸载"], + dest="uninstall plugin", + help_text="卸载插件", + ), + Subcommand( + "update", + alias={"更新"}, + dest="update local store index", + help_text="更新本地索引库", + ), + Subcommand( + "upgrade", + Args["package_name", str], + Option( + "package_name", + Args["package_name", str, None], # Optional + ), + alias={"升级"}, + dest="upgrade all plugins without package name", + help_text="升级插件", + ), + ), +) diff --git a/src/plugins/packmanv2/npm/data_source.py b/src/plugins/packmanv2/npm/data_source.py index 94ab782b..f142d786 100644 --- a/src/plugins/packmanv2/npm/data_source.py +++ b/src/plugins/packmanv2/npm/data_source.py @@ -1,6 +1,28 @@ +import json +from pathlib import Path + +import aiofiles +from pydantic import BaseModel + +from src.utils.base.config import get_config +from src.utils.io import fetch + + class Session: def __init__(self, session_type: str, session_id: int | str): self.session_type = session_type self.session_id = session_id +async def update_local_store_index() -> list[str]: + """ + 更新本地插件索引库 + Returns: + 新增插件包名列表list[str] + """ + url = "https://registry.nonebot.dev/plugins.json" + save_file = Path(get_config("data_path"), "data/liteyuki") / "pacman/plugins.json" + raw_text = await fetch(url) + data = json.loads(raw_text) + with aiofiles.open(save_file, "w") as f: + await f.write(raw_text) diff --git a/src/test/action_test.py b/src/test/action_test.py deleted file mode 100644 index 74d7c540..00000000 --- a/src/test/action_test.py +++ /dev/null @@ -1,29 +0,0 @@ -import nonebot -from src.utils import adapter_manager, driver_manager, init -from src.utils.base.config import load_from_yaml -from src.utils.base.data_manager import StoredConfig, common_db -from src.utils.base.ly_api import liteyuki_api - -if __name__ == "__mp_main__": - # Start as multiprocessing - init() - store_config: dict = common_db.where_one(StoredConfig(), default=StoredConfig()).config - static_config = load_from_yaml("config.yml") - store_config.update(static_config) - driver_manager.init(config=store_config) - adapter_manager.init(store_config) - nonebot.init(**store_config) - adapter_manager.register() - try: - nonebot.load_plugin("liteyuki.liteyuki_main") - nonebot.load_from_toml("pyproject.toml") - except BaseException as e: - if not isinstance(e, KeyboardInterrupt): - nonebot.logger.error(f"An error occurred: {e}, Bug will be reported automatically.") - liteyuki_api.bug_report(str(e.__repr__())) - -if __name__ == "__main__": - # Start as __main__ - from src.utils.base.reloader import Reloader - - nonebot.run() diff --git a/src/utils/base/config.py b/src/utils/base/config.py index a53f8f9e..5d5c5c8f 100644 --- a/src/utils/base/config.py +++ b/src/utils/base/config.py @@ -32,6 +32,7 @@ class BasicConfig(BaseModel): command_start: list[str] = ["/", ""] nickname: list[str] = [f"LiteyukiBot-{random_hex_string(6)}"] satori: SatoriConfig = SatoriConfig() + data_path: str = "data/liteyuki" def load_from_yaml(file: str) -> dict: @@ -95,6 +96,8 @@ def init_conf(conf: dict) -> dict: """ # 若command_start中无"",则添加必要命令头,开启alconna_use_command_start防止冲突 - if "" not in conf.get("command_start", []): - conf["alconna_use_command_start"] = True + # 以下内容由于issue #53 被注释 + # if "" not in conf.get("command_start", []): + # conf["alconna_use_command_start"] = True return conf + pass diff --git a/src/utils/base/ly_api.py b/src/utils/base/ly_api.py index 9b8d16d2..3b79b90e 100644 --- a/src/utils/base/ly_api.py +++ b/src/utils/base/ly_api.py @@ -20,8 +20,9 @@ class LiteyukiAPI: 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("Auto bug report is enabled") + nonebot.logger.info("Auto report enabled") @property def device_info(self) -> dict: @@ -37,10 +38,10 @@ class LiteyukiAPI: "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 / 1024 / 1024:.2f}GB", - "memory_used" : f"{psutil.virtual_memory().used / 1024 / 1024 / 1024:.2f}GB", - "memory_bot" : f"{psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024:.2f}MB", - "disk" : f"{psutil.disk_usage('/').total / 1024 / 1024 / 1024:.2f}GB" + "memory_total": f"{psutil.virtual_memory().total / 1024 ** 3:.2f}GB", + "memory_used" : f"{psutil.virtual_memory().used / 1024 ** 3:.2f}GB", + "memory_bot" : f"{psutil.Process(os.getpid()).memory_info().rss / 1024 ** 2:.2f}MB", + "disk" : f"{psutil.disk_usage('/').total / 1024 ** 3:.2f}GB" } def bug_report(self, content: str): @@ -77,14 +78,11 @@ class LiteyukiAPI: url = "https://api.liteyuki.icu/heartbeat" data = { "liteyuki_id": self.liteyuki_id, - "version": __VERSION__, + "version" : __VERSION__, } async with aiohttp.ClientSession() as session: async with session.post(url, json=data) as resp: if resp.status == 200: nonebot.logger.success("Heartbeat sent successfully") else: - nonebot.logger.error(f"Heartbeat failed: {await resp.text()}") - - -liteyuki_api = LiteyukiAPI() + nonebot.logger.error(f"Heartbeat failed: {await resp.text()}") \ No newline at end of file diff --git a/src/utils/base/reloader.py b/src/utils/base/reloader.py deleted file mode 100644 index 6b27342d..00000000 --- a/src/utils/base/reloader.py +++ /dev/null @@ -1,61 +0,0 @@ -import threading -from multiprocessing import get_context - -import nonebot -from nonebot import logger - -reboot_grace_time_limit: int = 20 - -_nb_run = nonebot.run - - -class Reloader: - event: threading.Event = None - - @classmethod - def reload(cls, delay: int = 0): - if cls.event is None: - raise RuntimeError() - if delay > 0: - threading.Timer(delay, function=cls.event.set).start() - return - cls.event.set() - - -def _run(ev: threading.Event, *args, **kwargs): - Reloader.event = ev - _nb_run(*args, **kwargs) - - -def run(*args, **kwargs): - should_exit = False - ctx = get_context("spawn") - while not should_exit: - event = ctx.Event() - process = ctx.Process( - target=_run, - args=( - event, - *args, - ), - kwargs=kwargs, - ) - process.start() - while not should_exit: - if event.wait(1): - logger.info("Receive reboot event") - process.terminate() - process.join(reboot_grace_time_limit) - if process.is_alive(): - logger.warning( - f"Cannot shutdown gracefully in {reboot_grace_time_limit} second, force kill process." - ) - process.kill() - break - elif process.is_alive(): - continue - else: - should_exit = True - - -nonebot.run = run diff --git a/src/utils/driver_manager/auto_set_env.py b/src/utils/driver_manager/auto_set_env.py index f3b2f204..0492713d 100644 --- a/src/utils/driver_manager/auto_set_env.py +++ b/src/utils/driver_manager/auto_set_env.py @@ -9,7 +9,6 @@ from .defines import * def auto_set_env(config: dict): dotenv.load_dotenv(".env") if os.getenv("DRIVER", None) is not None: - print(os.getenv("DRIVER")) nonebot.logger.info("Driver already set in environment variable, skip auto configure.") return if config.get("satori", {'enable': False}).get("enable", False): diff --git a/src/utils/network/__init__.py b/src/utils/io/__init__.py similarity index 87% rename from src/utils/network/__init__.py rename to src/utils/io/__init__.py index 62c4eac0..bc30c167 100644 --- a/src/utils/network/__init__.py +++ b/src/utils/io/__init__.py @@ -1,15 +1,17 @@ -from aiohttp import ClientSession - - -async def simple_get(url: str) -> str: - """ - 简单异步get请求 - Args: - url: - - Returns: - - """ - async with ClientSession() as session: - async with session.get(url) as resp: - return await resp.text() +from aiohttp import ClientSession + +from .net import * +from .file import * + + +async def simple_get(url: str) -> str: + """ + 简单异步get请求 + Args: + url: + + Returns: + """ + async with ClientSession() as session: + async with session.get(url) as resp: + return await resp.text() diff --git a/src/utils/io/file.py b/src/utils/io/file.py new file mode 100644 index 00000000..c25a9c7d --- /dev/null +++ b/src/utils/io/file.py @@ -0,0 +1,29 @@ +import aiofiles + + +async def write_file( + file_path: str, + content: str | bytes, + mode: str = "w" +): + """ + 写入文件 + Args: + mode: 写入模式 + file_path: 文件路径 + content: 内容 + """ + async with aiofiles.open(file_path, mode) as f: + await f.write(content) + + +async def read_file(file_path: str, mode: str = "r") -> str: + """ + 读取文件 + Args: + file_path: 文件路径 + mode: 读取模式 + Returns: + """ + async with aiofiles.open(file_path, mode) as f: + return await f.read() diff --git a/src/utils/io/net.py b/src/utils/io/net.py new file mode 100644 index 00000000..c346aa47 --- /dev/null +++ b/src/utils/io/net.py @@ -0,0 +1,12 @@ +async def fetch(url: str) -> str: + """ + 异步get请求 + Args: + url: + + Returns: + + """ + async with ClientSession() as session: + async with session.get(url) as resp: + return await resp.text() diff --git a/test/test_core.py b/test/test_core.py new file mode 100644 index 00000000..81da20e8 --- /dev/null +++ b/test/test_core.py @@ -0,0 +1,5 @@ +from src.liteyuki import LiteyukiBot + +if __name__ == "__main__": + lyb = LiteyukiBot() + lyb.run() \ No newline at end of file diff --git a/src/liteyuki/plugins/__init__.py b/test/test_lyapi.py similarity index 100% rename from src/liteyuki/plugins/__init__.py rename to test/test_lyapi.py diff --git a/src/liteyuki/plugins/plugin_loader/__init__.py b/test/test_lyfunc.py similarity index 100% rename from src/liteyuki/plugins/plugin_loader/__init__.py rename to test/test_lyfunc.py