diff --git a/core/data/temp/help_menu.png b/core/data/temp/help_menu.png new file mode 100644 index 0000000..7750044 Binary files /dev/null and b/core/data/temp/help_menu.png differ diff --git a/core/managers/__init__.py b/core/managers/__init__.py index 843b996..a563ad1 100644 --- a/core/managers/__init__.py +++ b/core/managers/__init__.py @@ -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", ] diff --git a/core/managers/browser_manager.py b/core/managers/browser_manager.py new file mode 100644 index 0000000..0670251 --- /dev/null +++ b/core/managers/browser_manager.py @@ -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() diff --git a/core/managers/command_manager.py b/core/managers/command_manager.py index cb90b79..43d9091 100644 --- a/core/managers/command_manager.py +++ b/core/managers/command_manager.py @@ -7,12 +7,18 @@ """ 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 ..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 +65,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 +200,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", "暂无描述") diff --git a/core/managers/image_manager.py b/core/managers/image_manager.py new file mode 100644 index 0000000..e73732b --- /dev/null +++ b/core/managers/image_manager.py @@ -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() diff --git a/main.py b/main.py index 936d63f..c94ae9c 100644 --- a/main.py +++ b/main.py @@ -15,8 +15,9 @@ from core.utils.logger import logger from core.managers.admin_manager import admin_manager 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.browser_manager import browser_manager from core.utils.executor import run_in_thread_pool, initialize_executor 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") +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): """ 文件变更处理器,用于热重载插件 @@ -102,9 +112,15 @@ async def main(): # 初始化 Redis 连接 await redis_manager.initialize() + # 同步帮助图片 + await matcher.sync_help_pic() + # 初始化管理员管理器 await admin_manager.initialize() + # 初始化浏览器管理器 + await browser_manager.initialize() + # 启动文件监控 # 监控 plugins 目录 plugin_path = os.path.join(os.path.dirname(__file__), "plugins") diff --git a/templates/help.html b/templates/help.html new file mode 100644 index 0000000..4fdc65e --- /dev/null +++ b/templates/help.html @@ -0,0 +1,134 @@ + + +
+ + +功能插件列表
+