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

添加浏览器管理器和图片管理器,用于通过 Playwright 渲染帮助菜单为图片
重构命令管理器以支持图片缓存和同步功能
添加 HTML 模板用于帮助菜单渲染
This commit is contained in:
2026-01-11 21:10:42 +08:00
parent 862ed0d581
commit de1f2e5119
8 changed files with 403 additions and 7 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,16 @@
""" """
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 ..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 +63,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 +198,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", "暂无描述")
@@ -171,9 +222,8 @@ class CommandManager:
help_text += f"\n{name}:\n" help_text += f"\n{name}:\n"
help_text += f" 功能: {description}\n" help_text += f" 功能: {description}\n"
help_text += f" 用法: {usage}\n" help_text += f" 用法: {usage}\n"
await bot.send(event, MessageSegment.image(help_pic)) await bot.send(event, help_text.strip())
# await bot.send(event, help_text.strip())
# 实例化全局唯一的命令管理器 # 实例化全局唯一的命令管理器

File diff suppressed because one or more lines are too long

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>