From 9819b43656623f037f474d47f6a1ba288ffd4e02 Mon Sep 17 00:00:00 2001 From: liuwei Date: Mon, 20 Apr 2026 10:19:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=8F=9C=E5=8D=95):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=96=87=E6=9C=AC/=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=E8=BE=93=E5=87=BA=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5md2image\n\n-=20=E6=96=B0=E5=A2=9E=20RobotMenu.output?= =?UTF-8?q?=5Fmode=20=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=94=AF=E6=8C=81=20text?= =?UTF-8?q?=20=E4=B8=8E=20image=20=E4=B8=A4=E7=A7=8D=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=8F=91=E9=80=81=E6=A8=A1=E5=BC=8F\n-=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=EF=BC=9A?= =?UTF-8?q?image=5Ffallback=5Fto=5Ftext=E3=80=81image=5Frender=5Ftimeout?= =?UTF-8?q?=5Fseconds=E3=80=81image=5Frender=5Fretries\n-=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E8=8F=9C=E5=8D=95=E7=8A=B6=E6=80=81=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=94=AF=E6=8C=81=E6=8C=89=E9=85=8D=E7=BD=AE=E8=B5=B0?= =?UTF-8?q?=20md2image=20=E7=94=9F=E6=88=90=E5=9B=BE=E7=89=87=E5=90=8E?= =?UTF-8?q?=E5=8F=91=E9=80=81\n-=20=E6=96=B0=E5=A2=9E=E8=8F=9C=E5=8D=95=20?= =?UTF-8?q?Markdown=20=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=AD=E5=B1=95=E7=A4=BA=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=9A=84=E5=BA=8F=E5=8F=B7=E3=80=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E9=94=AE=E3=80=81=E7=8A=B6=E6=80=81=E3=80=81=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E3=80=81=E6=8C=87=E4=BB=A4=E4=BF=A1=E6=81=AF\n-=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8F=91=E9=80=81=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E9=85=8D=E7=BD=AE=E5=86=B3=E5=AE=9A=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E6=96=87=E6=9C=AC=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97\n-=20=E8=A1=A5=E5=85=85=E8=AF=A6=E7=BB=86=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A=E5=B9=B6=E4=BF=9D=E6=8C=81=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/robot_menu/config.toml | 11 +++ plugins/robot_menu/main.py | 152 +++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/plugins/robot_menu/config.toml b/plugins/robot_menu/config.toml index 265c5f2..71644f8 100644 --- a/plugins/robot_menu/config.toml +++ b/plugins/robot_menu/config.toml @@ -1,6 +1,17 @@ [RobotMenu] enable = true command = ["菜单", "功能菜单"] +# 菜单输出模式: +# - text:发送文本菜单(历史行为) +# - image:先用 md2image 将 Markdown 渲染为图片后发送 +output_mode = "image" +# 图片生成失败时是否回退文本菜单: +# - false:严格按图片模式,不发送完整菜单文本 +# - true:优先保证可达,失败后改发文本 +image_fallback_to_text = false +# md2image 渲染参数:可按服务器性能调整 +image_render_timeout_seconds = 45 +image_render_retries = 1 command-format = """ 📝功能菜单指令: 菜单 - 显示功能菜单 diff --git a/plugins/robot_menu/main.py b/plugins/robot_menu/main.py index 5c6753a..b9e755d 100644 --- a/plugins/robot_menu/main.py +++ b/plugins/robot_menu/main.py @@ -1,3 +1,6 @@ +import asyncio +import time +from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from loguru import logger @@ -8,6 +11,7 @@ from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.plugin_interface import PluginStatus from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.points_decorator import plugin_points_cost +from utils.markdown_to_image import convert_md_str_to_image from utils.revoke.message_auto_revoke import MessageAutoRevoke from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager from utils.wechat.contact_manager import ContactManager @@ -70,6 +74,25 @@ class RobotMenuPlugin(MessagePluginInterface): self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"]) self.command_format = self._config.get("RobotMenu", {}).get("command-format", "菜单 功能名") self.enable = self._config.get("RobotMenu", {}).get("enable", True) + # 输出模式配置:支持 text / image 两种模式。 + # 业务诉求是“菜单默认发图片”,因此配置文件默认会写 image; + # 为兼容历史配置,这里做兜底归一化,非法值统一降级到 text。 + self.menu_output_mode = self._normalize_output_mode( + self._config.get("RobotMenu", {}).get("output_mode", "text") + ) + # 图片生成失败时是否回退到文本: + # - True:优先保证消息可达; + # - False:严格按“只发图片”执行(失败时仅提示失败原因)。 + self.image_fallback_to_text = bool( + self._config.get("RobotMenu", {}).get("image_fallback_to_text", False) + ) + # 菜单图片渲染超时与重试配置,避免极端环境下长时间阻塞消息处理协程。 + self.image_render_timeout_seconds = int( + self._config.get("RobotMenu", {}).get("image_render_timeout_seconds", 45) + ) + self.image_render_retries = int( + self._config.get("RobotMenu", {}).get("image_render_retries", 1) + ) self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}") return True @@ -151,6 +174,110 @@ class RobotMenuPlugin(MessagePluginInterface): return "当前没有启用机器人的群组" return "\n".join(group_list) + @staticmethod + def _normalize_output_mode(raw_mode: Any) -> str: + """将配置中的输出模式标准化为 text 或 image。""" + mode = str(raw_mode or "").strip().lower() + if mode in {"image", "img", "picture", "pic"}: + return "image" + return "text" + + @staticmethod + def _split_feature_description(feature_desc: str) -> Tuple[str, str]: + """拆分功能描述为“功能说明”和“指令说明”。 + + 约定: + 1. `Feature.description` 中使用 `[...]` 包裹指令或触发方式; + 2. 若未配置方括号,则指令说明使用“无”占位,保证菜单结构稳定。 + """ + desc = str(feature_desc or "").strip() + match = re.match(r"^(.*?)(?:\[(.*?)\])?$", desc) + if not match: + return desc or "未命名功能", "无" + title = (match.group(1) or "").strip() or "未命名功能" + cmd_hint = (match.group(2) or "").strip() or "无" + return title, cmd_hint + + def _build_feature_status_markdown(self, group_id: str) -> str: + """构建菜单图片使用的 Markdown 文本。 + + 输出目标: + 1. 逐项列出每个插件(Feature)当前状态; + 2. 明确展示每个插件的功能描述与指令/触发方式; + 3. 让用户在一张图片里即可完成“查看状态 + 查指令”。 + """ + lines = [ + "# 机器人功能菜单", + "", + f"- 目标:`{group_id}`", + f"- 生成时间:`{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}`", + "", + "| 序号 | 功能键 | 状态 | 功能说明 | 指令/触发方式 |", + "| --- | --- | --- | --- | --- |", + ] + for feature in Feature: + status = GroupBotManager.get_group_permission(group_id, feature) + status_text = "启用 ✅" if status == PermissionStatus.ENABLED else "关闭 ❌" + title, cmd_hint = self._split_feature_description(feature.description) + lines.append( + f"| {feature.value} | `{feature.name}` | {status_text} | {title} | `{cmd_hint}` |" + ) + return "\n".join(lines) + + async def _send_menu_content( + self, + bot: WechatAPIClient, + target: str, + sender: str, + revoke: MessageAutoRevoke, + text_content: str, + markdown_content: Optional[str] = None, + revoke_seconds: int = 90, + ) -> None: + """按配置发送菜单内容(文本或图片)。 + + 发送策略: + 1. `output_mode=text`:走历史文本发送逻辑,并登记自动撤回; + 2. `output_mode=image`:先用 md2image 渲染图片后发送; + 3. 图片失败时根据 `image_fallback_to_text` 决定是否回退文本。 + """ + # 文本模式:保持原有行为,避免影响已有群聊使用习惯。 + if self.menu_output_mode != "image": + client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, text_content, sender) + revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, revoke_seconds) + return + + # 图片模式:将菜单 Markdown 转图后发送。 + md_content = (markdown_content or "").strip() or f"```text\n{text_content}\n```" + output_image = f"robot_menu_{int(time.time() * 1000)}.png" + try: + # 增加总超时保护,防止图片渲染阻塞主流程过久。 + total_timeout = max(30, self.image_render_timeout_seconds * max(1, self.image_render_retries) + 10) + image_path = await asyncio.wait_for( + convert_md_str_to_image( + md_content, + output_image, + max_retries=max(1, self.image_render_retries), + render_timeout_seconds=max(10, self.image_render_timeout_seconds), + html_timeout_seconds=min(30, max(10, self.image_render_timeout_seconds)), + ), + timeout=total_timeout, + ) + await bot.send_image_message(target, Path(image_path)) + except Exception as e: + self.LOG.error(f"[{self.name}] 菜单图片发送失败: {e}") + if self.image_fallback_to_text: + client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, text_content, sender) + revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, revoke_seconds) + else: + # 严格图片模式下不回退完整菜单文本,仅发送简短失败提示。 + client_msg_id, create_time, new_msg_id = await bot.send_text_message( + target, + "❌ 菜单图片生成失败,请稍后重试", + sender + ) + revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 30) + def at_list(self, xml): try: root = ET.fromstring(xml) @@ -193,9 +320,16 @@ class RobotMenuPlugin(MessagePluginInterface): if len(parts) == 1: # 显示功能菜单 menu_text = self.get_enabled_features(roomid if roomid else sender) - - client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, menu_text, sender) - revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90) + menu_markdown = self._build_feature_status_markdown(roomid if roomid else sender) + await self._send_menu_content( + bot=bot, + target=target, + sender=sender, + revoke=revoke, + text_content=menu_text, + markdown_content=menu_markdown, + revoke_seconds=90, + ) return True, "显示功能菜单" # 提取命令名 @@ -206,8 +340,16 @@ class RobotMenuPlugin(MessagePluginInterface): if cmd_name.upper() == "状态": # 显示所有功能状态 status_text = self.display_menu_status(roomid if roomid else sender) - client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, status_text, sender) - revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90) + status_markdown = self._build_feature_status_markdown(roomid if roomid else sender) + await self._send_menu_content( + bot=bot, + target=target, + sender=sender, + revoke=revoke, + text_content=status_text, + markdown_content=status_markdown, + revoke_seconds=90, + ) return True, "显示功能状态" # 处理群列表命令