feat(菜单): 支持菜单文本/图片可配置输出并接入md2image\n\n- 新增 RobotMenu.output_mode 配置,支持 text 与 image 两种菜单发送模式\n- 新增图片相关配置:image_fallback_to_text、image_render_timeout_seconds、image_render_retries\n- 菜单与菜单状态命令支持按配置走 md2image 生成图片后发送\n- 新增菜单 Markdown 生成逻辑,图片中展示每个插件的序号、功能键、状态、说明、指令信息\n- 图片发送失败时按配置决定是否回退文本,并补充失败提示与日志\n- 补充详细中文注释并保持原文本发送逻辑兼容

This commit is contained in:
liuwei
2026-04-20 10:19:16 +08:00
parent 7768cebe42
commit 9819b43656
2 changed files with 158 additions and 5 deletions

View File

@@ -1,6 +1,17 @@
[RobotMenu] [RobotMenu]
enable = true enable = true
command = ["菜单", "功能菜单"] 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 = """ command-format = """
📝功能菜单指令: 📝功能菜单指令:
菜单 - 显示功能菜单 菜单 - 显示功能菜单

View File

@@ -1,3 +1,6 @@
import asyncio
import time
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from loguru import logger 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 base.plugin_common.plugin_interface import PluginStatus
from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.decorator.points_decorator import plugin_points_cost 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.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager
from utils.wechat.contact_manager import ContactManager from utils.wechat.contact_manager import ContactManager
@@ -70,6 +74,25 @@ class RobotMenuPlugin(MessagePluginInterface):
self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"]) self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"])
self.command_format = self._config.get("RobotMenu", {}).get("command-format", "菜单 功能名") self.command_format = self._config.get("RobotMenu", {}).get("command-format", "菜单 功能名")
self.enable = self._config.get("RobotMenu", {}).get("enable", True) 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}") self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
return True return True
@@ -151,6 +174,110 @@ class RobotMenuPlugin(MessagePluginInterface):
return "当前没有启用机器人的群组" return "当前没有启用机器人的群组"
return "\n".join(group_list) 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): def at_list(self, xml):
try: try:
root = ET.fromstring(xml) root = ET.fromstring(xml)
@@ -193,9 +320,16 @@ class RobotMenuPlugin(MessagePluginInterface):
if len(parts) == 1: if len(parts) == 1:
# 显示功能菜单 # 显示功能菜单
menu_text = self.get_enabled_features(roomid if roomid else sender) menu_text = self.get_enabled_features(roomid if roomid else sender)
menu_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, menu_text, sender) await self._send_menu_content(
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90) bot=bot,
target=target,
sender=sender,
revoke=revoke,
text_content=menu_text,
markdown_content=menu_markdown,
revoke_seconds=90,
)
return True, "显示功能菜单" return True, "显示功能菜单"
# 提取命令名 # 提取命令名
@@ -206,8 +340,16 @@ class RobotMenuPlugin(MessagePluginInterface):
if cmd_name.upper() == "状态": if cmd_name.upper() == "状态":
# 显示所有功能状态 # 显示所有功能状态
status_text = self.display_menu_status(roomid if roomid else sender) 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) status_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90) 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, "显示功能状态" return True, "显示功能状态"
# 处理群列表命令 # 处理群列表命令