feat(help): 重构帮助系统为图片渲染模式
添加浏览器管理器和图片管理器,用于通过 Playwright 渲染帮助菜单为图片 重构命令管理器以支持图片缓存和同步功能 添加 HTML 模板用于帮助菜单渲染
This commit is contained in:
BIN
core/data/temp/help_menu.png
Normal file
BIN
core/data/temp/help_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -9,6 +9,8 @@ from .command_manager import matcher as command_manager
|
||||
from .permission_manager import PermissionManager
|
||||
from .plugin_manager import PluginManager
|
||||
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_manager = RedisManager()
|
||||
|
||||
# 浏览器管理器
|
||||
browser_manager = BrowserManager()
|
||||
|
||||
# 图片管理器
|
||||
image_manager = ImageManager()
|
||||
|
||||
__all__ = [
|
||||
"admin_manager",
|
||||
"permission_manager",
|
||||
@@ -35,4 +43,6 @@ __all__ = [
|
||||
"matcher",
|
||||
"plugin_manager",
|
||||
"redis_manager",
|
||||
"browser_manager",
|
||||
"image_manager",
|
||||
]
|
||||
|
||||
72
core/managers/browser_manager.py
Normal file
72
core/managers/browser_manager.py
Normal 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()
|
||||
# 启动 Chromium,headless=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()
|
||||
@@ -7,12 +7,16 @@
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
import os
|
||||
import base64
|
||||
|
||||
from models.events.message import MessageSegment
|
||||
|
||||
from ..config_loader import global_config
|
||||
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
|
||||
@@ -59,6 +63,40 @@ class CommandManager:
|
||||
# 注册内置的 /help 命令
|
||||
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):
|
||||
"""
|
||||
注册框架内置的命令
|
||||
@@ -160,9 +198,22 @@ class CommandManager:
|
||||
async def _help_command(self, bot, event):
|
||||
"""
|
||||
内置的 `/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():
|
||||
name = meta.get("name", "未命名插件")
|
||||
description = meta.get("description", "暂无描述")
|
||||
@@ -171,9 +222,8 @@ class CommandManager:
|
||||
help_text += f"\n{name}:\n"
|
||||
help_text += f" 功能: {description}\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
115
core/managers/image_manager.py
Normal file
115
core/managers/image_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user