diff --git a/README.md b/README.md index 7927926..00dbdd1 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ ## 手动安装和部署 -1. 前置:`Git`和`Python3.10+` -2. 使用命令`git clone https://github.com/snowykami/LiteyukiBot` -3. 切换到轻雪目录,使用`pip install -r requirements.txt`安装依赖 -4. `python main.py`启动! +1. 安装`Git`和`Python3.10+` +2. 克隆项目到本地`git clone https://github.com/snowykami/LiteyukiBot` +3. 切换到轻雪目录`cd LiteyukiBot` +4. 安装依赖`pip install -r requirements.txt` +5. 启动`python main.py` ## 一键部署脚本(复制到本地保存执行) @@ -36,7 +37,7 @@ git clone https://github.com/snowykami/LiteyukiBot cd LiteyukiBot pip install -r requirements.txt echo python3 main.py > start.bat -echo Install finished! Please run start.bat to start the bot! +echo Install finished! Please click "start.bat" to start the bot! ``` #### Linux @@ -47,33 +48,29 @@ cd LiteyukiBot pip install -r requirements.txt echo python3 main.py > start.sh chmod +x start.sh -echo Install finished! Please run start.sh to start the bot! +echo Install finished! Please run "sh start.sh" to start the bot! ``` ## 注意事项 -- 尽可能不要去动配置文件,通过与bot交互进行配置即可,若仍然想自定义配置请在`config.yml`中修改 - - 首次启动会提醒用户注册超级用户 - Bot会自动检测新版本,若出现新版本,可用`git pull`命令更新 ### Onebot实现端配置 -| 字段 | 参考值 | 说明 | -|----|-------------------------------|-------------------------| -| 协议 | 反向WebSocket | 轻雪使用反向ws协议进行通信,即轻雪作为服务端 | -| 地址 | ws://`host`:`port`/onebot/v11 | 地址取决于配置文件,默认为`20216`端口 | +| 字段 | 参考值 | 说明 | +|----|-------------------------------|---------------------------| +| 协议 | 反向WebSocket | 轻雪默认使用反向ws协议进行通信,即轻雪作为服务端 | +| 地址 | ws://`host`:`port`/onebot/v11 | 地址取决于配置文件,默认为`20216`端口 | -### 推荐方案 +### 推荐方案(QQ) 1. 使用`Lagrange.Core`,`Lagrange.Core`支持多种协议 2. 云崽的`icqq-plugin`和`ws-plugin`进行通信 3. `Go-cqhttp`(目前已经半死不活了) 4. 人工实现的`Onebot`协议,自己整一个WebSocket客户端,看着QQ的消息,然后给轻雪传输数据 +### 推荐方案(Minecraft) +1. 我们有专门为Minecraft开发的服务器Bot,支持OnebotV11/12标准,详细请看[MinecraftOneBot](https://github.com/snowykami/MinecraftOnebot) 请先自行查阅文档,若有困难请联系相关开发者而不是Liteyuki的开发者 ## 鸣谢 - -- html转图片使用的[kexue-z](https://github.com/kexue-z)的[nonebot-plugin-htmlrender](https://github.com/kexue-z/nonebot-plugin-htmlrender)插件的部分代码 -- 重启方案用的[18870](https://github.com/18870)的[Nonebot-plugin-reboot](https://github.com/18870/nonebot-plugin-reboot)插件的部分代码 -- Lagrange.Core的测试环境支持 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 005cc42..4c3012d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ arclet-alconna==2.0.0a1 +dash==2.16.1 nonebot2[fastapi]==2.2.1 nonebot-adapter-onebot==2.4.3 nonebot-plugin-alconna==0.41.0 +psutil==5.9.8 pydantic==2.6.4 \ No newline at end of file diff --git a/src/assets/lang/en.lang b/src/assets/lang/en.lang deleted file mode 100644 index e69de29..0000000 diff --git a/src/assets/lang/zh_cn.lang b/src/assets/lang/zh_cn.lang deleted file mode 100644 index e69de29..0000000 diff --git a/src/plugins/liteyuki_plugin_main/__init__.py b/src/plugins/liteyuki_plugin_main/__init__.py index 35b58be..a370438 100644 --- a/src/plugins/liteyuki_plugin_main/__init__.py +++ b/src/plugins/liteyuki_plugin_main/__init__.py @@ -1,5 +1,8 @@ import nonebot from nonebot.plugin import PluginMetadata +from src.utils.language import get_system_lang +from .loader import * +from .webdash import * __author__ = "snowykami" __plugin_meta__ = PluginMetadata( @@ -9,11 +12,8 @@ __plugin_meta__ = PluginMetadata( homepage="https://github.com/snowykami/LiteyukiBot", ) -fastapi_app = nonebot.get_app() +from src.utils.config import config - -@fastapi_app.get("/") -async def root(): - return { - "message": "Hello LiteyukiBot!", - } +sys_lang = get_system_lang() +nonebot.logger.info(sys_lang.get("main.current_language", LANG=sys_lang.get("language.name"))) +nonebot.logger.info(sys_lang.get("main.enable_webdash", URL=f"http://{config['nonebot']['host']}:{config['nonebot']['port']}")) diff --git a/src/plugins/liteyuki_plugin_main/load.py b/src/plugins/liteyuki_plugin_main/load.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/plugins/liteyuki_plugin_main/loader.py b/src/plugins/liteyuki_plugin_main/loader.py new file mode 100644 index 0000000..5259521 --- /dev/null +++ b/src/plugins/liteyuki_plugin_main/loader.py @@ -0,0 +1,14 @@ +import os + +import nonebot.plugin + +from src.utils.language import load_from_dir +from src.utils.resource import load_resource_from_dir + +PLUGIN_NAME = os.path.basename(os.path.dirname(__file__)) +RESOURCE_PATH = "src/resources" +load_resource_from_dir(RESOURCE_PATH) + +for plugin_dir in os.listdir("src/plugins"): + if plugin_dir != PLUGIN_NAME: + nonebot.plugin.load_plugin(f"src.plugins.{plugin_dir}") \ No newline at end of file diff --git a/src/plugins/liteyuki_plugin_main/webdash.py b/src/plugins/liteyuki_plugin_main/webdash.py new file mode 100644 index 0000000..3e65e73 --- /dev/null +++ b/src/plugins/liteyuki_plugin_main/webdash.py @@ -0,0 +1,80 @@ +import nonebot +import psutil +from dash import Dash, Input, Output, dcc, html +from starlette.middleware.wsgi import WSGIMiddleware + +from src.utils.language import Language + +app = nonebot.get_app() + + +def get_system_info(): + cpu_percent = psutil.cpu_percent() + memory_info = psutil.virtual_memory() + memory_percent = memory_info.percent + return { + "cpu_percent" : cpu_percent, + "memory_percent": memory_percent + } + + +@app.get("/system_info") +async def system_info(): + return get_system_info() + +lang = Language() +dash_app = Dash(__name__) +dash_app.layout = dash_app.layout = html.Div(children=[ + html.H1(children=lang.get("main.monitor.title"), style={ + 'textAlign': 'center' + }), + + dcc.Graph(id='live-update-graph'), + dcc.Interval( + id='interval-component', + interval=1 * 1000, # in milliseconds + n_intervals=0 + ) +]) + + +@dash_app.callback(Output('live-update-graph', 'figure'), + [Input('interval-component', 'n_intervals')]) +def update_graph_live(n): + lang = Language() + system_inf = get_system_info() + dash_app.layout = html.Div(children=[ + html.H1(children=lang.get("main.monitor.title"), style={ + 'textAlign': 'center' + }), + + dcc.Graph(id='live-update-graph'), + dcc.Interval( + id='interval-component', + interval=1 * 1000, # in milliseconds + n_intervals=0 + ) + ]) + figure = { + 'data' : [ + { + 'x' : [lang.get('main.monitor.cpu')], + 'y' : [system_inf['cpu_percent']], + 'type': 'bar', + 'name': f"{lang.get('main.monitor.cpu')} {lang.get('main.monitor.usage')}" + }, + { + 'x' : [lang.get('main.monitor.memory')], + 'y' : [system_inf['memory_percent']], + 'type': 'bar', + 'name': f"{lang.get('main.monitor.memory')} {lang.get('main.monitor.usage')}" + }, + ], + 'layout': { + 'title': lang.get('main.monitor.description'), + } + } + return figure + + +app.mount("/", WSGIMiddleware(dash_app.server)) diff --git a/src/plugins/liteyuki_plugin_npm/manager.py b/src/plugins/liteyuki_plugin_npm/manager.py index 54eb53d..792ffe4 100644 --- a/src/plugins/liteyuki_plugin_npm/manager.py +++ b/src/plugins/liteyuki_plugin_npm/manager.py @@ -1,5 +1,7 @@ import nonebot.plugin from nonebot import on_command +from nonebot.permission import SUPERUSER + from src.utils.adapter import MessageEvent from src.utils.language import get_user_lang @@ -9,8 +11,12 @@ toggle_plugin = on_command("enable-plugin", aliases={"启用插件", "禁用插 @list_plugins.handle() async def _(event: MessageEvent): - lang = get_user_lang(event.user_id) - reply = lang.get("npm.current_plugins") + lang = get_user_lang(str(event.user_id)) + reply = lang.get("npm.loaded_plugins") for plugin in nonebot.get_loaded_plugins(): - reply += f"\n- {plugin.name}" + # 检查是否有 metadata 属性 + if plugin.metadata: + reply += f"\n- {plugin.metadata.name}" + else: + reply += f"\n- {plugin.name}" await list_plugins.finish(reply) diff --git a/src/plugins/liteyuki_plugin_user/__init__.py b/src/plugins/liteyuki_plugin_user/__init__.py new file mode 100644 index 0000000..f8f3222 --- /dev/null +++ b/src/plugins/liteyuki_plugin_user/__init__.py @@ -0,0 +1 @@ +from .profile_manager import * \ No newline at end of file diff --git a/src/plugins/liteyuki_plugin_user/profile_manager.py b/src/plugins/liteyuki_plugin_user/profile_manager.py new file mode 100644 index 0000000..5c8e4a7 --- /dev/null +++ b/src/plugins/liteyuki_plugin_user/profile_manager.py @@ -0,0 +1,39 @@ +from nonebot import on_command +from nonebot.params import CommandArg + +from src.utils.adapter import Bot, Message, MessageEvent +from src.utils.data_manager import User, user_db +from src.utils.language import get_user_lang + + + +attr_map = { + "lang" : ["lang", "language", "语言"], + "username": ["username", "昵称", "用户名"] # Bot称呼用户的昵称 +} + +attr_cmd = on_command("profile", aliases={"个人设置"}, priority=0) + + +@attr_cmd.handle() +async def _(bot: Bot, event: MessageEvent, args: Message = CommandArg()): + user = user_db.first(User, "user_id = ?", str(event.user_id), default=User(user_id=str(event.user_id))) + ulang = get_user_lang(str(event.user_id)) + + args = str(args).split(" ", 1) + input_key = args[0] + attr_key = "username" + for attr_key, attr_values in attr_map.items(): + if input_key in attr_values: + break + + if len(args) == 1: + # 查询 + value = user.__dict__[attr_key] + await attr_cmd.finish(f"{ulang.get('user.profile_manager.query', ATTR=attr_key, VALUE=value)}") + else: + # 设置 + value = args[1] + user.__dict__[attr_key] = value + user_db.save(user) + await attr_cmd.finish(f"{ulang.get('user.profile_manager.set', ATTR=attr_key, VALUE=value)}") diff --git a/src/resources/fonts/bold.ttf b/src/resources/fonts/bold.ttf new file mode 100644 index 0000000..eda4bea Binary files /dev/null and b/src/resources/fonts/bold.ttf differ diff --git a/src/resources/fonts/heavy.ttf b/src/resources/fonts/heavy.ttf new file mode 100644 index 0000000..16d5c2b Binary files /dev/null and b/src/resources/fonts/heavy.ttf differ diff --git a/src/resources/fonts/normal.ttf b/src/resources/fonts/normal.ttf new file mode 100644 index 0000000..efaedf2 Binary files /dev/null and b/src/resources/fonts/normal.ttf differ diff --git a/src/resources/lang/en.lang b/src/resources/lang/en.lang new file mode 100644 index 0000000..1980947 --- /dev/null +++ b/src/resources/lang/en.lang @@ -0,0 +1,16 @@ +language.name=English + +main.current_language=Current system language: {LANG} +main.enable_webdash=Web dashboard is enabled: {URL} +main.monitor.title=Liteyuki Monitor +main.monitor.description=Monitor your server with Liteyuki Monitor +main.monitor.cpu=CPU +main.monitor.memory=MEM +main.monitor.swap=SWAP +main.monitor.disk=Disk +main.monitor.usage=Usage + +npm.loaded_plugins=Loaded plugins + +user.profile_manager.query=Your {ATTR} is {VALUE} +user.profile_manager.set=Your {ATTR} has been set to {VALUE} \ No newline at end of file diff --git a/src/resources/lang/zh-CN.lang b/src/resources/lang/zh-CN.lang new file mode 100644 index 0000000..1636f06 --- /dev/null +++ b/src/resources/lang/zh-CN.lang @@ -0,0 +1,17 @@ +language.name=简体中文 + +main.current_language=当前系统语言为: {LANG} +main.enable_webdash=已启用网页监控面板: {URL} +main.monitor.title=轻雪监控面板 +main.monitor.description=轻雪机器人监控面板 +main.monitor.cpu=处理器 +main.monitor.memory=内存 +main.monitor.swap=交换空间 +main.monitor.disk=磁盘 +main.monitor.usage=使用率 + +npm.loaded_plugins=已加载插件 +npm.loaded_plugins.description=已加载的插件列表 + +user.profile_manager.query=你的个人信息 {ATTR} 为 {VALUE} +user.profile_manager.set=你的个人信息 {ATTR} 已设置为 {VALUE} \ No newline at end of file diff --git a/src/resources/metadata.yml b/src/resources/metadata.yml new file mode 100644 index 0000000..214632c --- /dev/null +++ b/src/resources/metadata.yml @@ -0,0 +1,3 @@ +name: 轻雪资源包 +description: 轻雪内置资源包,不可卸载 +version: 1.0.0 \ No newline at end of file diff --git a/src/utils/adapter.py b/src/utils/adapter.py index f5d3813..e7950f3 100644 --- a/src/utils/adapter.py +++ b/src/utils/adapter.py @@ -5,3 +5,5 @@ Bot = v11.Bot | v12.Bot GroupMessageEvent = v11.GroupMessageEvent | v12.GroupMessageEvent PrivateMessageEvent = v11.PrivateMessageEvent | v12.PrivateMessageEvent MessageEvent = v11.MessageEvent | v12.MessageEvent + +Message = v11.Message | v12.Message diff --git a/src/utils/config.py b/src/utils/config.py index d09e529..978c222 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,6 +1,10 @@ from yaml import load, FullLoader +config = None + def load_from_yaml(file: str) -> dict: + global config with open(file, 'r', encoding='utf-8') as f: - return load(f, Loader=FullLoader) + config = load(f, Loader=FullLoader) + return config diff --git a/src/utils/data.py b/src/utils/data.py index 5d61244..0188d36 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -1,9 +1,12 @@ import json +import os import sqlite3 import types from abc import ABC from collections.abc import Iterable from typing import Any + +import nonebot from pydantic import BaseModel BaseIterable = list | tuple | set | dict @@ -89,6 +92,8 @@ class SqliteORMDatabase(BaseORMAdapter): def __init__(self, db_name: str): super().__init__() + if not os.path.exists(os.path.dirname(db_name)): + os.makedirs(os.path.dirname(db_name)) self.conn = sqlite3.connect(db_name) self.conn.row_factory = sqlite3.Row self.cursor = self.conn.cursor() @@ -136,12 +141,13 @@ class SqliteORMDatabase(BaseORMAdapter): # 检测新字段 for field, type_ in zip(model_fields, model_types): if field not in table_fields: - print(f'ALTER TABLE {table_name} ADD COLUMN {field} {type_}') + nonebot.logger.debug(f'ALTER TABLE {table_name} ADD COLUMN {field} {type_}') self.cursor.execute(f'ALTER TABLE {table_name} ADD COLUMN {field} {type_}') # 检测多余字段,除了id字段 for field in table_fields: if field not in model_fields and field != 'id': + nonebot.logger.debug(f'ALTER TABLE {table_name} DROP COLUMN {field}') self.cursor.execute(f'ALTER TABLE {table_name} DROP COLUMN {field}') self.conn.commit() @@ -172,6 +178,7 @@ class SqliteORMDatabase(BaseORMAdapter): key_list.append(field) value_list.append(value) # 更新或插入数据,用?占位 + nonebot.logger.debug(f'INSERT OR REPLACE INTO {table_name} ({",".join(key_list)}) VALUES ({",".join(["?" for _ in key_list])})') self.cursor.execute(f'INSERT OR REPLACE INTO {table_name} ({",".join(key_list)}) VALUES ({",".join(["?" for _ in key_list])})', value_list) ids.append(self.cursor.lastrowid) @@ -223,8 +230,9 @@ class SqliteORMDatabase(BaseORMAdapter): """ table_name = model.__name__ self.cursor.execute(f'SELECT * FROM {table_name} WHERE {conditions}', args) - data = dict(self.cursor.fetchone()) - return model(**self.convert_to_dict(data)) if data else default + if data := self.cursor.fetchone(): + return model(**self.convert_to_dict(data)) + return default def all(self, model: type(LiteModel), conditions, *args, default: Any = None) -> list[LiteModel] | None: """查询所有数据 @@ -254,6 +262,7 @@ class SqliteORMDatabase(BaseORMAdapter): """ table_name = model.__name__ + nonebot.logger.debug(f'DELETE FROM {table_name} WHERE {conditions}') self.cursor.execute(f'DELETE FROM {table_name} WHERE {conditions}', args) self.conn.commit() @@ -270,6 +279,7 @@ class SqliteORMDatabase(BaseORMAdapter): """ table_name = model.__name__ + nonebot.logger.debug(f'UPDATE {table_name} SET {operation} WHERE {conditions}') self.cursor.execute(f'UPDATE {table_name} SET {operation} WHERE {conditions}', args) self.conn.commit() diff --git a/src/utils/data_manager.py b/src/utils/data_manager.py index 6591de8..6ee4d90 100644 --- a/src/utils/data_manager.py +++ b/src/utils/data_manager.py @@ -7,10 +7,10 @@ DATA_PATH = "data/liteyuki" user_db = DB(os.path.join(DATA_PATH, 'users.ldb')) -class UserModel(LiteModel): - id: str - username: str - lang: str +class User(LiteModel): + user_id: str + username: str = "" + lang: str = "en" -user_db.auto_migrate(UserModel) +user_db.auto_migrate(User) diff --git a/src/utils/language.py b/src/utils/language.py index 2ca12f7..b6bd9cd 100644 --- a/src/utils/language.py +++ b/src/utils/language.py @@ -3,13 +3,15 @@ """ import json +import locale import os from typing import Any import nonebot -from src.utils.data_manager import UserModel, user_db +from src.utils.data_manager import User, user_db +_default_lang_code = "en" _language_data = { "en": { "name": "English", @@ -35,6 +37,7 @@ def load_from_lang(file_path: str, lang_code: str = None): if not line or line.startswith('#'): # 空行或注释 continue key, value = line.split('=', 1) + nonebot.logger.debug(f"Loaded language text: {key.strip()} -> {value.strip()}") data[key.strip()] = value.strip() if lang_code not in _language_data: _language_data[lang_code] = {} @@ -59,10 +62,31 @@ def load_from_json(file_path: str, lang_code: str = None): if lang_code not in _language_data: _language_data[lang_code] = {} _language_data[lang_code].update(data) + nonebot.logger.debug(f"Loaded language data from {file_path}") except Exception as e: nonebot.logger.error(f"Failed to load language data from {file_path}: {e}") +def load_from_dir(dir_path: str): + """ + 从目录中加载语言数据 + + Args: + dir_path: 目录路径 + """ + for file in os.listdir(dir_path): + try: + file_path = os.path.join(dir_path, file) + if os.path.isfile(file_path): + if file.endswith('.lang'): + load_from_lang(file_path) + elif file.endswith('.json'): + load_from_json(file_path) + except Exception as e: + nonebot.logger.error(f"Failed to load language data from {file}: {e}") + continue + + def load_from_dict(data: dict, lang_code: str): """ 从字典中加载语言数据 @@ -77,11 +101,13 @@ def load_from_dict(data: dict, lang_code: str): class Language: - def __init__(self, lang_code: str = "en", fallback_lang_code: str = "en"): + def __init__(self, lang_code: str = None, fallback_lang_code: str = "en"): + if lang_code is None: + lang_code = get_system_lang_code() self.lang_code = lang_code self.fallback_lang_code = fallback_lang_code - def get(self, item: str, *args) -> str | Any: + def get(self, item: str, *args, **kwargs) -> str | Any: """ 获取当前语言文本 Args: @@ -94,9 +120,9 @@ class Language: """ try: if self.lang_code in _language_data and item in _language_data[self.lang_code]: - return _language_data[self.lang_code][item].format(*args) + return _language_data[self.lang_code][item].format(*args, **kwargs) if self.fallback_lang_code in _language_data and item in _language_data[self.fallback_lang_code]: - return _language_data[self.fallback_lang_code][item].format(*args) + return _language_data[self.fallback_lang_code][item].format(*args, **kwargs) return item except Exception as e: nonebot.logger.error(f"Failed to get language text or format: {e}") @@ -107,5 +133,19 @@ def get_user_lang(user_id: str) -> Language: """ 获取用户的语言代码 """ - user = user_db.first(UserModel, "id = ?", user_id, default=UserModel(id=user_id, username="Unknown", lang="en")) + user = user_db.first(User, "user_id = ?", user_id, default=User(user_id=user_id, username="Unknown", lang="en")) return Language(user.lang) + + +def get_system_lang_code() -> str: + """ + 获取系统语言代码 + """ + return locale.getdefaultlocale()[0].replace('_', '-') + + +def get_system_lang() -> Language: + """ + 获取系统语言 + """ + return Language(get_system_lang_code()) diff --git a/src/utils/resource.py b/src/utils/resource.py new file mode 100644 index 0000000..51974ef --- /dev/null +++ b/src/utils/resource.py @@ -0,0 +1,43 @@ +import os + +import nonebot +import yaml + +from src.utils.data import LiteModel + +_resource_data = {} +_loaded_resource_packs = [] # 按照加载顺序排序 + + +class ResourceMetadata(LiteModel): + name: str = "Unknown" + version: str = "0.0.1" + description: str = "Unknown" + path: str + + +def load_resource_from_dir(path: str): + """ + 把资源包按照文件相对路径加载到资源包中,后加载的优先级更高,顺便加载语言 + Args: + path: 资源文件夹 + + Returns: + + """ + for root, dirs, files in os.walk(path): + for file in files: + relative_path = os.path.relpath(os.path.join(root, file), path).replace("\\", "/") + abs_path = os.path.join(root, file).replace("\\", "/") + _resource_data[relative_path] = abs_path + nonebot.logger.debug(f"Loaded {relative_path} -> {abs_path}") + if os.path.exists(os.path.join(path, "metadata.yml")): + with open(os.path.join(path, "metadata.yml"), "r", encoding="utf-8") as f: + metadata = yaml.safe_load(f) + else: + metadata = ResourceMetadata() + metadata["path"] = path + if os.path.exists(os.path.join(path, "lang")): + from src.utils.language import load_from_dir + load_from_dir(os.path.join(path, "lang")) + _loaded_resource_packs.append(ResourceMetadata(**metadata))