* 滚木

* feat: 重构核心架构,增强类型安全与插件管理

本次提交对核心模块进行了深度重构,引入 Pydantic 增强配置管理的类型安全性,并全面优化了插件管理系统。

主要变更详情:

1. 核心架构与配置
   - 重构配置加载模块:引入 Pydantic 模型 (`core/config_models.py`),提供严格的配置项类型检查、验证及默认值管理。
   - 统一模块结构:规范化模块导入路径,移除冗余的 `__init__.py` 文件,提升项目结构的清晰度。
   - 性能优化:集成 Redis 缓存支持 (`RedisManager`),有效降低高频 API 调用开销,提升响应速度。

2. 插件系统升级
   - 实现热重载机制:新增插件文件变更监听功能,支持开发过程中自动重载插件,提升开发效率。
   - 优化生命周期管理:改进插件加载与卸载逻辑,支持精确卸载指定插件及其关联的命令、事件处理器和定时任务。

3. 功能特性增强
   - 新增媒体 API:引入 `MediaAPI` 模块,封装图片、语音等富媒体资源的获取与处理接口。
   - 完善权限体系:重构权限管理系统,实现管理员与操作员的分级控制,支持更细粒度的命令权限校验。

4. 代码质量与稳定性
   - 全面类型修复:解决 `mypy` 静态类型检查发现的大量类型错误(包括 `CommandManager`、`EventFactory` 及 `Bot` API 签名不匹配问题)。
   - 增强错误处理:优化消息处理管道的异常捕获机制,完善关键路径的日志记录,提升系统运行稳定性。

* feat: 添加测试用例并优化代码结构

refactor(permission_manager): 调整初始化顺序和逻辑
fix(admin_manager): 修复初始化逻辑和目录创建问题
feat(ws): 优化Bot实例初始化条件
feat(message): 增强MessageSegment功能并添加测试
feat(events): 支持字符串格式的消息解析
test: 添加核心功能测试用例
refactor(plugin_manager): 改进插件路径处理
style: 清理无用导入和代码
chore: 更新依赖项

* refactor(handler): 移除TYPE_CHECKING并直接导入Bot类

简化类型注解,直接导入Bot类而非使用TYPE_CHECKING条件导入,提高代码可读性和维护性

* fix(command_manager): 修复插件卸载时元信息移除不精确的问题

修复 CommandManager 中 unload_plugin 方法移除插件元信息时使用 startswith 导致可能误删其他插件的问题,改为精确匹配
同时调整相关测试用例验证精确匹配行为

* refactor: 清理未使用的导入和更新文档结构

docs: 添加config_models.py到项目结构文档
docs: 调整数据目录位置到core/data下
docs: 更新权限管理器文档描述

* 文档更新

* 更新thpic插件 支持一次返回多张图

* feat: 添加测试覆盖率并修复相关问题

refactor(redis_manager): 移除冗余的ConnectionError处理
refactor(event_handler): 优化Bot类型注解
refactor(factory): 移除未使用的GroupCardNoticeEvent

test: 添加全面的单元测试覆盖
- 添加test_import.py测试模块导入
- 添加test_debug.py测试插件加载调试
- 添加test_plugin_error.py测试错误处理
- 添加test_config_loader.py测试配置加载
- 添加test_redis_manager.py测试Redis管理
- 添加test_bot.py测试Bot功能
- 扩展test_models.py测试消息模型
- 添加test_plugin_manager_coverage.py测试插件管理
- 添加test_executor.py测试代码执行器
- 添加test_ws.py测试WebSocket
- 添加test_api.py测试API接口
- 添加test_core_managers.py测试核心管理模块

fix(plugin_manager): 修复插件加载日志变量问题

覆盖率已到达86%(忽略插件)

* 更新/help指令,现在会发送图片

* feat(help): 重构帮助系统为图片渲染模式

添加浏览器管理器和图片管理器,用于通过 Playwright 渲染帮助菜单为图片
重构命令管理器以支持图片缓存和同步功能
添加 HTML 模板用于帮助菜单渲染

---------

Co-authored-by: baby20162016 <2185823427@qq.com>
This commit is contained in:
镀铬酸钾
2026-01-11 21:11:13 +08:00
committed by GitHub
parent 651d982e19
commit aec847d9f7
7 changed files with 403 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -9,6 +9,8 @@ from .command_manager import matcher as command_manager
from .permission_manager import PermissionManager from .permission_manager import PermissionManager
from .plugin_manager import PluginManager from .plugin_manager import PluginManager
from .redis_manager import RedisManager from .redis_manager import RedisManager
from .browser_manager import BrowserManager
from .image_manager import ImageManager
# --- 实例化所有单例管理器 --- # --- 实例化所有单例管理器 ---
@@ -28,6 +30,12 @@ plugin_manager.load_all_plugins()
# Redis 管理器 # Redis 管理器
redis_manager = RedisManager() redis_manager = RedisManager()
# 浏览器管理器
browser_manager = BrowserManager()
# 图片管理器
image_manager = ImageManager()
__all__ = [ __all__ = [
"admin_manager", "admin_manager",
"permission_manager", "permission_manager",
@@ -35,4 +43,6 @@ __all__ = [
"matcher", "matcher",
"plugin_manager", "plugin_manager",
"redis_manager", "redis_manager",
"browser_manager",
"image_manager",
] ]

View File

@@ -0,0 +1,72 @@
"""
浏览器管理器模块
负责管理全局唯一的 Playwright 浏览器实例,避免频繁启动/关闭浏览器的开销。
"""
import asyncio
from typing import Optional
from playwright.async_api import async_playwright, Browser, Playwright, Page
from ..utils.logger import logger
class BrowserManager:
"""
浏览器管理器(异步单例)
"""
_instance = None
_playwright: Optional[Playwright] = None
_browser: Optional[Browser] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def initialize(self):
"""
初始化 Playwright 和 Browser
"""
if self._browser is None:
try:
logger.info("正在启动无头浏览器...")
self._playwright = await async_playwright().start()
# 启动 Chromiumheadless=True 表示无头模式
self._browser = await self._playwright.chromium.launch(headless=True)
logger.success("无头浏览器启动成功!")
except Exception as e:
logger.exception(f"无头浏览器启动失败: {e}")
self._browser = None
async def get_new_page(self) -> Optional[Page]:
"""
获取一个新的页面 (Page)
使用完毕后,调用者应该负责关闭该页面 (await page.close())
"""
if self._browser is None:
logger.warning("浏览器尚未初始化,尝试重新初始化...")
await self.initialize()
if self._browser:
try:
return await self._browser.new_page()
except Exception as e:
logger.error(f"创建新页面失败: {e}")
return None
return None
async def shutdown(self):
"""
关闭浏览器和 Playwright
"""
if self._browser:
await self._browser.close()
self._browser = None
logger.info("浏览器已关闭")
if self._playwright:
await self._playwright.stop()
self._playwright = None
logger.info("Playwright 已停止")
# 全局浏览器管理器实例
browser_manager = BrowserManager()

View File

@@ -7,12 +7,18 @@
""" """
from typing import Any, Callable, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
import os
import base64
from models.events.message import MessageSegment
from models.events.message import MessageSegment from models.events.message import MessageSegment
from ..config_loader import global_config from ..config_loader import global_config
from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler
from .help_pic import help_pic from .redis_manager import redis_manager
from .image_manager import image_manager
from ..utils.logger import logger
# 从配置中获取命令前缀 # 从配置中获取命令前缀
_config_prefixes = global_config.bot.command _config_prefixes = global_config.bot.command
@@ -59,6 +65,40 @@ class CommandManager:
# 注册内置的 /help 命令 # 注册内置的 /help 命令
self._register_internal_commands() self._register_internal_commands()
async def sync_help_pic(self):
"""
启动时或插件重载时同步 help 图片到 Redis
"""
try:
logger.info("正在生成帮助图片...")
# 1. 收集插件数据
plugins_data = []
for plugin_name, meta in self.plugins.items():
plugins_data.append({
"name": meta.get("name", plugin_name),
"description": meta.get("description", "暂无描述"),
"usage": meta.get("usage", "暂无用法")
})
# 2. 渲染图片
# 使用 png 格式以获得更好的文字清晰度
base64_str = await image_manager.render_template_to_base64(
template_name="help.html",
data={"plugins": plugins_data},
output_name="help_menu.png",
image_type="png"
)
if base64_str:
await redis_manager.set("neobot:core:help_pic", base64_str)
logger.success("帮助图片已更新并缓存到 Redis")
else:
logger.error("帮助图片生成失败")
except Exception as e:
logger.error(f"同步帮助图片失败: {e}")
def _register_internal_commands(self): def _register_internal_commands(self):
""" """
注册框架内置的命令 注册框架内置的命令
@@ -160,9 +200,22 @@ class CommandManager:
async def _help_command(self, bot, event): async def _help_command(self, bot, event):
""" """
内置的 `/help` 命令的实现。 内置的 `/help` 命令的实现。
直接从 Redis 获取缓存的图片。
""" """
help_text = "--- 可用指令列表 ---\n" # 1. 尝试从 Redis 获取
help_pic = await redis_manager.get("neobot:core:help_pic")
if not help_pic:
await bot.send(event, "帮助图片缓存缺失,正在重新生成...")
await self.sync_help_pic()
help_pic = await redis_manager.get("neobot:core:help_pic")
if help_pic:
await bot.send(event, MessageSegment.image(help_pic))
return
# 2. 最后的兜底:发送纯文本
help_text = "--- 可用指令列表 ---\n"
for plugin_name, meta in self.plugins.items(): for plugin_name, meta in self.plugins.items():
name = meta.get("name", "未命名插件") name = meta.get("name", "未命名插件")
description = meta.get("description", "暂无描述") description = meta.get("description", "暂无描述")

View File

@@ -0,0 +1,115 @@
"""
图片生成管理器模块
负责管理图片生成相关的逻辑,支持多种渲染引擎(目前支持 Playwright
"""
import os
import base64
from typing import Dict, Any, Optional
from jinja2 import Template
from .browser_manager import browser_manager
from ..utils.logger import logger
class ImageManager:
"""
图片生成管理器(单例)
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# 模板目录
self.template_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
# 临时文件目录
# core/managers/image_manager.py -> core/managers -> core -> core/data/temp
self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "temp")
os.makedirs(self.temp_dir, exist_ok=True)
async def render_template(self, template_name: str, data: Dict[str, Any], output_name: str = "output.png", quality: int = 80, image_type: str = "png") -> Optional[str]:
"""
使用 Playwright 渲染 Jinja2 模板并保存为图片文件
Args:
template_name (str): 模板文件名 (例如 "help.html")
data (Dict[str, Any]): 传递给模板的数据字典
output_name (str, optional): 输出文件名. Defaults to "output.png".
quality (int, optional): JPEG 质量 (0-100). 仅在 image_type 为 jpeg 时有效. Defaults to 80.
image_type (str, optional): 图片类型 ('png' or 'jpeg'). Defaults to "png".
Returns:
Optional[str]: 生成图片的绝对路径,如果失败则返回 None
"""
template_path = os.path.join(self.template_dir, template_name)
if not os.path.exists(template_path):
logger.error(f"模板文件未找到: {template_path}")
return None
try:
# 1. 渲染 HTML
with open(template_path, "r", encoding="utf-8") as f:
template_str = f.read()
template = Template(template_str)
html_content = template.render(**data)
# 2. 使用浏览器截图
page = await browser_manager.get_new_page()
if not page:
logger.error("无法获取浏览器页面")
return None
try:
# 设置视口
await page.set_viewport_size({"width": 650, "height": 100})
# 加载内容
await page.set_content(html_content)
await page.wait_for_selector("body")
# 截图
screenshot_args = {'full_page': True, 'type': image_type}
if image_type == 'jpeg':
screenshot_args['quality'] = quality
screenshot_bytes = await page.screenshot(**screenshot_args)
finally:
await page.close()
# 3. 保存文件
output_path = os.path.join(self.temp_dir, output_name)
with open(output_path, "wb") as f:
f.write(screenshot_bytes)
logger.info(f"图片已生成: {output_path} ({len(screenshot_bytes)/1024:.2f} KB)")
return os.path.abspath(output_path)
except Exception as e:
logger.exception(f"渲染模板 {template_name} 失败: {e}")
return None
async def render_template_to_base64(self, template_name: str, data: Dict[str, Any], output_name: str = "output.png", quality: int = 80, image_type: str = "png") -> Optional[str]:
"""
渲染模板并返回 Base64 编码的图片字符串
"""
file_path = await self.render_template(template_name, data, output_name, quality, image_type)
if not file_path:
return None
try:
with open(file_path, "rb") as f:
content = f.read()
mime_type = "image/jpeg" if image_type == "jpeg" else "image/png"
return f"data:{mime_type};base64," + base64.b64encode(content).decode("utf-8")
except Exception as e:
logger.error(f"读取图片文件失败: {e}")
return None
# 全局图片管理器实例
image_manager = ImageManager()

18
main.py
View File

@@ -15,8 +15,9 @@ from core.utils.logger import logger
from core.managers.admin_manager import admin_manager from core.managers.admin_manager import admin_manager
from core.ws import WS from core.ws import WS
from core.managers import plugin_manager from core.managers import plugin_manager, matcher
from core.managers.redis_manager import redis_manager from core.managers.redis_manager import redis_manager
from core.managers.browser_manager import browser_manager
from core.utils.executor import run_in_thread_pool, initialize_executor from core.utils.executor import run_in_thread_pool, initialize_executor
from core.config_loader import global_config as config from core.config_loader import global_config as config
@@ -29,6 +30,15 @@ sys.path.insert(0, ROOT_DIR)
PLUGIN_DIR = os.path.join(ROOT_DIR, "plugins") PLUGIN_DIR = os.path.join(ROOT_DIR, "plugins")
async def reload_plugin_and_sync_help(module_name: str):
"""
重载插件并同步帮助图片
"""
await run_in_thread_pool(plugin_manager.reload_plugin, module_name)
# 插件重载后,重新生成帮助图片
await matcher.sync_help_pic()
class PluginReloadHandler(FileSystemEventHandler): class PluginReloadHandler(FileSystemEventHandler):
""" """
文件变更处理器,用于热重载插件 文件变更处理器,用于热重载插件
@@ -102,9 +112,15 @@ async def main():
# 初始化 Redis 连接 # 初始化 Redis 连接
await redis_manager.initialize() await redis_manager.initialize()
# 同步帮助图片
await matcher.sync_help_pic()
# 初始化管理员管理器 # 初始化管理员管理器
await admin_manager.initialize() await admin_manager.initialize()
# 初始化浏览器管理器
await browser_manager.initialize()
# 启动文件监控 # 启动文件监控
# 监控 plugins 目录 # 监控 plugins 目录
plugin_path = os.path.join(os.path.dirname(__file__), "plugins") plugin_path = os.path.join(os.path.dirname(__file__), "plugins")

134
templates/help.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoBot 帮助菜单</title>
<style>
:root {
--primary-color: #4a90e2;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--text-color: #333333;
--secondary-text: #666666;
--border-radius: 12px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
body {
font-family: 'Microsoft YaHei', 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
}
.container {
width: 600px;
background-color: var(--bg-color);
}
.header {
text-align: center;
margin-bottom: 30px;
padding: 20px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--border-radius);
color: white;
box-shadow: var(--shadow);
}
.header h1 {
margin: 0;
font-size: 2.5em;
letter-spacing: 2px;
}
.header p {
margin: 10px 0 0;
opacity: 0.9;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.plugin-card {
background-color: var(--card-bg);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--shadow);
transition: transform 0.2s;
border-left: 5px solid var(--primary-color);
}
.plugin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.plugin-name {
font-size: 1.2em;
font-weight: bold;
color: var(--primary-color);
}
.plugin-desc {
color: var(--secondary-text);
margin-bottom: 15px;
line-height: 1.5;
}
.plugin-usage {
background-color: #f8f9fa;
padding: 10px;
border-radius: 6px;
font-family: 'Consolas', monospace;
font-size: 0.9em;
color: #d63384;
border: 1px solid #e9ecef;
}
.footer {
text-align: center;
margin-top: 30px;
color: var(--secondary-text);
font-size: 0.8em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>NeoBot</h1>
<p>功能插件列表</p>
</div>
<div class="plugin-list">
{% for plugin in plugins %}
<div class="plugin-card">
<div class="plugin-header">
<span class="plugin-name">{{ plugin.name }}</span>
</div>
<div class="plugin-desc">{{ plugin.description }}</div>
<div class="plugin-usage">
{{ plugin.usage }}
</div>
</div>
{% endfor %}
</div>
<div class="footer">
Generated by NeoBot • Playwright Rendering
</div>
</div>
</body>
</html>