feat: 添加了网页监控面板

This commit is contained in:
远野千束 2024-03-19 00:27:40 +08:00
parent 51cb1a87b8
commit 3adc265876
23 changed files with 316 additions and 42 deletions

View File

@ -19,10 +19,11 @@
## 手动安装和部署 ## 手动安装和部署
1. 前置:`Git`和`Python3.10+` 1. 安装`Git`和`Python3.10+`
2. 使用命令`git clone https://github.com/snowykami/LiteyukiBot` 2. 克隆项目到本地`git clone https://github.com/snowykami/LiteyukiBot`
3. 切换到轻雪目录,使用`pip install -r requirements.txt`安装依赖 3. 切换到轻雪目录`cd LiteyukiBot`
4. `python main.py`启动! 4. 安装依赖`pip install -r requirements.txt`
5. 启动`python main.py`
## 一键部署脚本(复制到本地保存执行) ## 一键部署脚本(复制到本地保存执行)
@ -36,7 +37,7 @@ git clone https://github.com/snowykami/LiteyukiBot
cd LiteyukiBot cd LiteyukiBot
pip install -r requirements.txt pip install -r requirements.txt
echo python3 main.py > start.bat 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 #### Linux
@ -47,33 +48,29 @@ cd LiteyukiBot
pip install -r requirements.txt pip install -r requirements.txt
echo python3 main.py > start.sh echo python3 main.py > start.sh
chmod +x 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`命令更新 - Bot会自动检测新版本若出现新版本可用`git pull`命令更新
### Onebot实现端配置 ### Onebot实现端配置
| 字段 | 参考值 | 说明 | | 字段 | 参考值 | 说明 |
|----|-------------------------------|-------------------------| |----|-------------------------------|---------------------------|
| 协议 | 反向WebSocket | 轻雪使用反向ws协议进行通信即轻雪作为服务端 | | 协议 | 反向WebSocket | 轻雪默认使用反向ws协议进行通信即轻雪作为服务端 |
| 地址 | ws://`host`:`port`/onebot/v11 | 地址取决于配置文件,默认为`20216`端口 | | 地址 | ws://`host`:`port`/onebot/v11 | 地址取决于配置文件,默认为`20216`端口 |
### 推荐方案 ### 推荐方案(QQ)
1. 使用`Lagrange.Core``Lagrange.Core`支持多种协议 1. 使用`Lagrange.Core``Lagrange.Core`支持多种协议
2. 云崽的`icqq-plugin`和`ws-plugin`进行通信 2. 云崽的`icqq-plugin`和`ws-plugin`进行通信
3. `Go-cqhttp`(目前已经半死不活了) 3. `Go-cqhttp`(目前已经半死不活了)
4. 人工实现的`Onebot`协议自己整一个WebSocket客户端看着QQ的消息然后给轻雪传输数据 4. 人工实现的`Onebot`协议自己整一个WebSocket客户端看着QQ的消息然后给轻雪传输数据
### 推荐方案(Minecraft)
1. 我们有专门为Minecraft开发的服务器Bot支持OnebotV11/12标准详细请看[MinecraftOneBot](https://github.com/snowykami/MinecraftOnebot)
请先自行查阅文档若有困难请联系相关开发者而不是Liteyuki的开发者 请先自行查阅文档若有困难请联系相关开发者而不是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的测试环境支持

View File

@ -1,5 +1,7 @@
arclet-alconna==2.0.0a1 arclet-alconna==2.0.0a1
dash==2.16.1
nonebot2[fastapi]==2.2.1 nonebot2[fastapi]==2.2.1
nonebot-adapter-onebot==2.4.3 nonebot-adapter-onebot==2.4.3
nonebot-plugin-alconna==0.41.0 nonebot-plugin-alconna==0.41.0
psutil==5.9.8
pydantic==2.6.4 pydantic==2.6.4

View File

@ -1,5 +1,8 @@
import nonebot import nonebot
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from src.utils.language import get_system_lang
from .loader import *
from .webdash import *
__author__ = "snowykami" __author__ = "snowykami"
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
@ -9,11 +12,8 @@ __plugin_meta__ = PluginMetadata(
homepage="https://github.com/snowykami/LiteyukiBot", homepage="https://github.com/snowykami/LiteyukiBot",
) )
fastapi_app = nonebot.get_app() from src.utils.config import config
sys_lang = get_system_lang()
@fastapi_app.get("/") nonebot.logger.info(sys_lang.get("main.current_language", LANG=sys_lang.get("language.name")))
async def root(): nonebot.logger.info(sys_lang.get("main.enable_webdash", URL=f"http://{config['nonebot']['host']}:{config['nonebot']['port']}"))
return {
"message": "Hello LiteyukiBot!",
}

View File

@ -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}")

View File

@ -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))

View File

@ -1,5 +1,7 @@
import nonebot.plugin import nonebot.plugin
from nonebot import on_command from nonebot import on_command
from nonebot.permission import SUPERUSER
from src.utils.adapter import MessageEvent from src.utils.adapter import MessageEvent
from src.utils.language import get_user_lang from src.utils.language import get_user_lang
@ -9,8 +11,12 @@ toggle_plugin = on_command("enable-plugin", aliases={"启用插件", "禁用插
@list_plugins.handle() @list_plugins.handle()
async def _(event: MessageEvent): async def _(event: MessageEvent):
lang = get_user_lang(event.user_id) lang = get_user_lang(str(event.user_id))
reply = lang.get("npm.current_plugins") reply = lang.get("npm.loaded_plugins")
for plugin in nonebot.get_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) await list_plugins.finish(reply)

View File

@ -0,0 +1 @@
from .profile_manager import *

View File

@ -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)}")

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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}

View File

@ -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}

View File

@ -0,0 +1,3 @@
name: 轻雪资源包
description: 轻雪内置资源包,不可卸载
version: 1.0.0

View File

@ -5,3 +5,5 @@ Bot = v11.Bot | v12.Bot
GroupMessageEvent = v11.GroupMessageEvent | v12.GroupMessageEvent GroupMessageEvent = v11.GroupMessageEvent | v12.GroupMessageEvent
PrivateMessageEvent = v11.PrivateMessageEvent | v12.PrivateMessageEvent PrivateMessageEvent = v11.PrivateMessageEvent | v12.PrivateMessageEvent
MessageEvent = v11.MessageEvent | v12.MessageEvent MessageEvent = v11.MessageEvent | v12.MessageEvent
Message = v11.Message | v12.Message

View File

@ -1,6 +1,10 @@
from yaml import load, FullLoader from yaml import load, FullLoader
config = None
def load_from_yaml(file: str) -> dict: def load_from_yaml(file: str) -> dict:
global config
with open(file, 'r', encoding='utf-8') as f: with open(file, 'r', encoding='utf-8') as f:
return load(f, Loader=FullLoader) config = load(f, Loader=FullLoader)
return config

View File

@ -1,9 +1,12 @@
import json import json
import os
import sqlite3 import sqlite3
import types import types
from abc import ABC from abc import ABC
from collections.abc import Iterable from collections.abc import Iterable
from typing import Any from typing import Any
import nonebot
from pydantic import BaseModel from pydantic import BaseModel
BaseIterable = list | tuple | set | dict BaseIterable = list | tuple | set | dict
@ -89,6 +92,8 @@ class SqliteORMDatabase(BaseORMAdapter):
def __init__(self, db_name: str): def __init__(self, db_name: str):
super().__init__() 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 = sqlite3.connect(db_name)
self.conn.row_factory = sqlite3.Row self.conn.row_factory = sqlite3.Row
self.cursor = self.conn.cursor() self.cursor = self.conn.cursor()
@ -136,12 +141,13 @@ class SqliteORMDatabase(BaseORMAdapter):
# 检测新字段 # 检测新字段
for field, type_ in zip(model_fields, model_types): for field, type_ in zip(model_fields, model_types):
if field not in table_fields: 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_}') self.cursor.execute(f'ALTER TABLE {table_name} ADD COLUMN {field} {type_}')
# 检测多余字段除了id字段 # 检测多余字段除了id字段
for field in table_fields: for field in table_fields:
if field not in model_fields and field != 'id': 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.cursor.execute(f'ALTER TABLE {table_name} DROP COLUMN {field}')
self.conn.commit() self.conn.commit()
@ -172,6 +178,7 @@ class SqliteORMDatabase(BaseORMAdapter):
key_list.append(field) key_list.append(field)
value_list.append(value) 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) 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) ids.append(self.cursor.lastrowid)
@ -223,8 +230,9 @@ class SqliteORMDatabase(BaseORMAdapter):
""" """
table_name = model.__name__ table_name = model.__name__
self.cursor.execute(f'SELECT * FROM {table_name} WHERE {conditions}', args) self.cursor.execute(f'SELECT * FROM {table_name} WHERE {conditions}', args)
data = dict(self.cursor.fetchone()) if data := self.cursor.fetchone():
return model(**self.convert_to_dict(data)) if data else default return model(**self.convert_to_dict(data))
return default
def all(self, model: type(LiteModel), conditions, *args, default: Any = None) -> list[LiteModel] | None: 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__ 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.cursor.execute(f'DELETE FROM {table_name} WHERE {conditions}', args)
self.conn.commit() self.conn.commit()
@ -270,6 +279,7 @@ class SqliteORMDatabase(BaseORMAdapter):
""" """
table_name = model.__name__ 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.cursor.execute(f'UPDATE {table_name} SET {operation} WHERE {conditions}', args)
self.conn.commit() self.conn.commit()

View File

@ -7,10 +7,10 @@ DATA_PATH = "data/liteyuki"
user_db = DB(os.path.join(DATA_PATH, 'users.ldb')) user_db = DB(os.path.join(DATA_PATH, 'users.ldb'))
class UserModel(LiteModel): class User(LiteModel):
id: str user_id: str
username: str username: str = ""
lang: str lang: str = "en"
user_db.auto_migrate(UserModel) user_db.auto_migrate(User)

View File

@ -3,13 +3,15 @@
""" """
import json import json
import locale
import os import os
from typing import Any from typing import Any
import nonebot 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 = { _language_data = {
"en": { "en": {
"name": "English", "name": "English",
@ -35,6 +37,7 @@ def load_from_lang(file_path: str, lang_code: str = None):
if not line or line.startswith('#'): # 空行或注释 if not line or line.startswith('#'): # 空行或注释
continue continue
key, value = line.split('=', 1) key, value = line.split('=', 1)
nonebot.logger.debug(f"Loaded language text: {key.strip()} -> {value.strip()}")
data[key.strip()] = value.strip() data[key.strip()] = value.strip()
if lang_code not in _language_data: if lang_code not in _language_data:
_language_data[lang_code] = {} _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: if lang_code not in _language_data:
_language_data[lang_code] = {} _language_data[lang_code] = {}
_language_data[lang_code].update(data) _language_data[lang_code].update(data)
nonebot.logger.debug(f"Loaded language data from {file_path}")
except Exception as e: except Exception as e:
nonebot.logger.error(f"Failed to load language data from {file_path}: {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): def load_from_dict(data: dict, lang_code: str):
""" """
从字典中加载语言数据 从字典中加载语言数据
@ -77,11 +101,13 @@ def load_from_dict(data: dict, lang_code: str):
class Language: 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.lang_code = lang_code
self.fallback_lang_code = fallback_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: Args:
@ -94,9 +120,9 @@ class Language:
""" """
try: try:
if self.lang_code in _language_data and item in _language_data[self.lang_code]: 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]: 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 return item
except Exception as e: except Exception as e:
nonebot.logger.error(f"Failed to get language text or format: {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) 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())

43
src/utils/resource.py Normal file
View File

@ -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))