From b9edf51ca8705ea253ff44fc3596fab59fa9c74e Mon Sep 17 00:00:00 2001 From: liuwei Date: Mon, 20 Apr 2026 10:22:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=8F=9C=E5=8D=95):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89HTML=E6=A0=B7=E5=BC=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=8F=9C=E5=8D=95=E5=9B=BE=E7=89=87=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=8C=87=E4=BB=A4=E5=B1=95=E7=A4=BA\n\n-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=9C=BA=E5=99=A8=E4=BA=BA=E8=8F=9C=E5=8D=95=E4=B8=93?= =?UTF-8?q?=E7=94=A8=20HTML=20=E6=A8=A1=E6=9D=BF=E4=B8=8E=20CSS=20?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=20md2image=20=E9=BB=98=E8=AE=A4=20Markdown?= =?UTF-8?q?=20=E6=A0=B7=E5=BC=8F\n-=20=E8=8F=9C=E5=8D=95=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9F=BA=E7=A1=80=E5=91=BD=E4=BB=A4=E5=8C=BA?= =?UTF-8?q?=E3=80=81=E7=AE=A1=E7=90=86=E5=91=98=E5=91=BD=E4=BB=A4=E5=8C=BA?= =?UTF-8?q?=E3=80=81=E5=8A=9F=E8=83=BD=E6=98=8E=E7=BB=86=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=8C=BA=EF=BC=8C=E5=B1=95=E7=A4=BA=E6=9B=B4=E8=A7=84=E8=8C=83?= =?UTF-8?q?\n-=20=E6=AF=8F=E4=B8=AA=E5=8A=9F=E8=83=BD=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=8A=B6=E6=80=81=E5=BE=BD=E6=A0=87=E3=80=81?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=94=AE=E3=80=81=E8=A7=A6=E5=8F=91=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E4=B8=8E=E5=90=AF=E7=94=A8/=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4=E7=A4=BA=E4=BE=8B\n-=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BC=98=E5=85=88=20html=5Fto=5Fimage=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=EF=BC=8C=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BB=8D=E5=8F=AF=E5=9B=9E=E9=80=80?= =?UTF-8?q?=20Markdown=20=E8=BD=AC=E5=9B=BE=E5=85=9C=E5=BA=95\n-=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AF=A6=E7=BB=86=E4=B8=AD=E6=96=87=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=EF=BC=8C=E6=98=8E=E7=A1=AE=E6=B8=B2=E6=9F=93=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E3=80=81=E5=85=BC=E5=AE=B9=E7=AD=96=E7=95=A5=E4=B8=8E?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E4=BF=9D=E6=8A=A4=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/robot_menu/main.py | 283 +++++++++++++++++++++++++++++++++++-- 1 file changed, 271 insertions(+), 12 deletions(-) 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}
+
+

{html.escape(title)}

+

功能键:{html.escape(feature.name)}

+
+ {status_text} +
+
+

{command_examples}

+
+
+ """ + ) + + now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + return f""" + + + + + + + +
+
+

机器人功能菜单

+

目标群/会话:{html.escape(group_id)}

+

生成时间:{now_text}

+
+
+
+

基础命令

+
    +
  • 菜单:查看完整功能菜单(状态 + 详细指令)
  • +
  • 菜单 状态:查看所有功能当前启用状态
  • +
  • 菜单 群列表:查看已启用群机器人的群组清单
  • +
+
+
+

管理员命令

+
    +
  • 菜单 启用 序号 / 菜单 关闭 序号
  • +
  • 菜单 启用 功能键 / 菜单 关闭 功能键
  • +
  • 菜单 管理员 添加 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, "显示功能状态"