diff --git a/plugins/robot_menu/main.py b/plugins/robot_menu/main.py
index b9e755d..e3f4141 100644
--- a/plugins/robot_menu/main.py
+++ b/plugins/robot_menu/main.py
@@ -1,4 +1,6 @@
import asyncio
+import html
+import os
import time
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
@@ -11,7 +13,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.markdown_to_image import convert_md_str_to_image, html_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
@@ -224,6 +226,245 @@ class RobotMenuPlugin(MessagePluginInterface):
)
return "\n".join(lines)
+ def _get_feature_command_examples(self, feature: Feature) -> str:
+ """返回功能的详细指令示例文本。
+
+ 设计说明:
+ 1. `Feature.description` 中的 `[]` 内容通常是“触发指令”或“触发方式”;
+ 2. 当某些功能没有显式指令时,给出“自动触发/定时触发”提示,避免用户误以为缺失;
+ 3. 所有功能统一补充“启用/关闭管理命令”,让用户知道如何控制开关。
+ """
+ _, cmd_hint = self._split_feature_description(feature.description)
+ # 统一的功能开关命令模板:管理员可通过序号或功能键控制开关。
+ manage_hint = (
+ f"管理:菜单 启用 {feature.value} | 菜单 关闭 {feature.value} | "
+ f"菜单 启用 {feature.name} | 菜单 关闭 {feature.name}"
+ )
+ if cmd_hint == "无":
+ return f"触发:自动/定时触发(无直接聊天指令)
{manage_hint}"
+ return f"触发:{html.escape(cmd_hint)}
{manage_hint}"
+
+ def _build_feature_status_html(self, group_id: str) -> str:
+ """构建菜单图片使用的 HTML 文本(自定义视觉,不复用 md2image 默认样式)。
+
+ 目标:
+ 1. 清晰分区:基础命令、管理员命令、功能明细;
+ 2. 每个功能独立卡片,展示状态、用途、触发指令与管理命令;
+ 3. 页面在移动端宽度下也可完整展示,减少截图后拥挤感。
+ """
+ feature_cards: List[str] = []
+ for feature in Feature:
+ status = GroupBotManager.get_group_permission(group_id, feature)
+ enabled = status == PermissionStatus.ENABLED
+ status_class = "badge-on" if enabled else "badge-off"
+ status_text = "已启用" if enabled else "已关闭"
+ title, _ = self._split_feature_description(feature.description)
+ command_examples = self._get_feature_command_examples(feature)
+ feature_cards.append(
+ f"""
+
+
+
#{feature.value}
+
+
{status_text}
+
+
+
+ """
+ )
+
+ now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ return f"""
+
+
+
+
+
+
+ 基础命令
+
+ 菜单:查看完整功能菜单(状态 + 详细指令)
+ 菜单 状态:查看所有功能当前启用状态
+ 菜单 群列表:查看已启用群机器人的群组清单
+
+
+
+ 管理员命令
+
+ 菜单 启用 序号 / 菜单 关闭 序号
+ 菜单 启用 功能键 / 菜单 关闭 功能键
+ 菜单 管理员 添加 wxid/昵称、菜单 管理员 删除 wxid/昵称、菜单 管理员 列表
+
+
+
+ 功能明细
+
+ {''.join(feature_cards)}
+
+
+
+
+
+
+ """
+
async def _send_menu_content(
self,
bot: WechatAPIClient,
@@ -232,6 +473,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke: MessageAutoRevoke,
text_content: str,
markdown_content: Optional[str] = None,
+ html_content: Optional[str] = None,
revoke_seconds: int = 90,
) -> None:
"""按配置发送菜单内容(文本或图片)。
@@ -247,22 +489,35 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, revoke_seconds)
return
- # 图片模式:将菜单 Markdown 转图后发送。
+ # 图片模式:优先使用“自定义 HTML 模板”渲染图片,不使用 md2image 默认 Markdown 样式。
+ # 兼容策略:
+ # 1. 若调用方传了 html_content,则按该模板直接截图;
+ # 2. 若未传 html_content,才回退到 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,
- )
+ output_dir = Path(os.getcwd()) / "temp" / "md2image"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / output_image
+ if html_content and html_content.strip():
+ await asyncio.wait_for(
+ html_to_image(html_content, str(output_path)),
+ timeout=total_timeout,
+ )
+ image_path = str(output_path.resolve())
+ else:
+ 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}")
@@ -321,6 +576,7 @@ class RobotMenuPlugin(MessagePluginInterface):
# 显示功能菜单
menu_text = self.get_enabled_features(roomid if roomid else sender)
menu_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
+ menu_html = self._build_feature_status_html(roomid if roomid else sender)
await self._send_menu_content(
bot=bot,
target=target,
@@ -328,6 +584,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke=revoke,
text_content=menu_text,
markdown_content=menu_markdown,
+ html_content=menu_html,
revoke_seconds=90,
)
return True, "显示功能菜单"
@@ -341,6 +598,7 @@ class RobotMenuPlugin(MessagePluginInterface):
# 显示所有功能状态
status_text = self.display_menu_status(roomid if roomid else sender)
status_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
+ status_html = self._build_feature_status_html(roomid if roomid else sender)
await self._send_menu_content(
bot=bot,
target=target,
@@ -348,6 +606,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke=revoke,
text_content=status_text,
markdown_content=status_markdown,
+ html_content=status_html,
revoke_seconds=90,
)
return True, "显示功能状态"