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]
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 = """
📝功能菜单指令:
菜单 - 显示功能菜单

View File

@@ -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, "显示功能状态"
# 处理群列表命令