feat(菜单): 使用自定义HTML样式生成菜单图片并增强指令展示\n\n- 新增机器人菜单专用 HTML 模板与 CSS 视觉样式,不再依赖 md2image 默认 Markdown 样式\n- 菜单图片新增基础命令区、管理员命令区、功能明细卡片区,展示更规范\n- 每个功能卡片增加状态徽标、功能键、触发方式与启用/关闭管理命令示例\n- 图片发送逻辑改为优先 html_to_image 渲染,自定义模板失败时仍可回退 Markdown 转图兜底\n- 补充详细中文注释,明确渲染策略、兼容策略与超时保护设计

This commit is contained in:
liuwei
2026-04-20 10:22:43 +08:00
parent 9819b43656
commit b9edf51ca8

View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import html
import os
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple 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 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.markdown_to_image import convert_md_str_to_image, html_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
@@ -224,6 +226,245 @@ class RobotMenuPlugin(MessagePluginInterface):
) )
return "\n".join(lines) 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"触发:自动/定时触发(无直接聊天指令)<br>{manage_hint}"
return f"触发:{html.escape(cmd_hint)}<br>{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"""
<section class="feature-card">
<div class="feature-top">
<div class="feature-index">#{feature.value}</div>
<div class="feature-meta">
<h3>{html.escape(title)}</h3>
<p class="feature-key">功能键:<code>{html.escape(feature.name)}</code></p>
</div>
<span class="feature-badge {status_class}">{status_text}</span>
</div>
<div class="feature-body">
<p>{command_examples}</p>
</div>
</section>
"""
)
now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
return f"""
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {{
--bg: #f3f7fb;
--card: #ffffff;
--line: #d8e4ef;
--text: #1f2d3d;
--muted: #5e748a;
--brand: #0d6efd;
--brand-soft: #e7f1ff;
--ok: #1f9d5a;
--ok-soft: #e9f8f0;
--off: #c53d3d;
--off-soft: #fdecec;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
padding: 24px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
background: linear-gradient(180deg, #eef4fa 0%, #f8fbff 100%);
color: var(--text);
}}
.page {{
width: 820px;
margin: 0 auto;
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 12px 28px rgba(20, 47, 76, 0.10);
}}
.hero {{
padding: 22px 24px 18px 24px;
background: linear-gradient(135deg, #0d6efd 0%, #0aa2c0 100%);
color: #fff;
}}
.hero h1 {{
margin: 0 0 8px 0;
font-size: 28px;
}}
.hero p {{
margin: 4px 0;
font-size: 14px;
opacity: .96;
}}
.content {{
padding: 20px 22px 24px 22px;
}}
.block {{
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
}}
.block h2 {{
margin: 0 0 8px 0;
font-size: 18px;
color: #12395f;
}}
.block ul {{
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}}
.feature-list {{
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}}
.feature-card {{
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
background: #fff;
}}
.feature-top {{
display: flex;
align-items: center;
gap: 10px;
}}
.feature-index {{
min-width: 48px;
text-align: center;
font-weight: 700;
color: #114a84;
background: var(--brand-soft);
border: 1px solid #cfe2ff;
border-radius: 8px;
padding: 6px 8px;
}}
.feature-meta {{
flex: 1;
}}
.feature-meta h3 {{
margin: 0 0 2px 0;
font-size: 16px;
}}
.feature-key {{
margin: 0;
font-size: 13px;
color: var(--muted);
}}
.feature-badge {{
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}}
.badge-on {{
color: var(--ok);
background: var(--ok-soft);
border-color: #bee7cf;
}}
.badge-off {{
color: var(--off);
background: var(--off-soft);
border-color: #f3c7c7;
}}
.feature-body {{
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fbff;
border: 1px dashed #d7e6f5;
}}
.feature-body p {{
margin: 0;
font-size: 13px;
color: #3d5870;
line-height: 1.75;
}}
code {{
background: #eef6ff;
border: 1px solid #d2e6ff;
color: #12539a;
padding: 1px 6px;
border-radius: 6px;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="page">
<header class="hero">
<h1>机器人功能菜单</h1>
<p>目标群/会话:{html.escape(group_id)}</p>
<p>生成时间:{now_text}</p>
</header>
<main class="content">
<section class="block">
<h2>基础命令</h2>
<ul>
<li><code>菜单</code>:查看完整功能菜单(状态 + 详细指令)</li>
<li><code>菜单 状态</code>:查看所有功能当前启用状态</li>
<li><code>菜单 群列表</code>:查看已启用群机器人的群组清单</li>
</ul>
</section>
<section class="block">
<h2>管理员命令</h2>
<ul>
<li><code>菜单 启用 序号</code> / <code>菜单 关闭 序号</code></li>
<li><code>菜单 启用 功能键</code> / <code>菜单 关闭 功能键</code></li>
<li><code>菜单 管理员 添加 wxid/昵称</code>、<code>菜单 管理员 删除 wxid/昵称</code>、<code>菜单 管理员 列表</code></li>
</ul>
</section>
<section class="block">
<h2>功能明细</h2>
<div class="feature-list">
{''.join(feature_cards)}
</div>
</section>
</main>
</div>
</body>
</html>
"""
async def _send_menu_content( async def _send_menu_content(
self, self,
bot: WechatAPIClient, bot: WechatAPIClient,
@@ -232,6 +473,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke: MessageAutoRevoke, revoke: MessageAutoRevoke,
text_content: str, text_content: str,
markdown_content: Optional[str] = None, markdown_content: Optional[str] = None,
html_content: Optional[str] = None,
revoke_seconds: int = 90, revoke_seconds: int = 90,
) -> None: ) -> None:
"""按配置发送菜单内容(文本或图片)。 """按配置发送菜单内容(文本或图片)。
@@ -247,22 +489,35 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, revoke_seconds) revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, revoke_seconds)
return 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```" md_content = (markdown_content or "").strip() or f"```text\n{text_content}\n```"
output_image = f"robot_menu_{int(time.time() * 1000)}.png" output_image = f"robot_menu_{int(time.time() * 1000)}.png"
try: try:
# 增加总超时保护,防止图片渲染阻塞主流程过久。 # 增加总超时保护,防止图片渲染阻塞主流程过久。
total_timeout = max(30, self.image_render_timeout_seconds * max(1, self.image_render_retries) + 10) total_timeout = max(30, self.image_render_timeout_seconds * max(1, self.image_render_retries) + 10)
image_path = await asyncio.wait_for( output_dir = Path(os.getcwd()) / "temp" / "md2image"
convert_md_str_to_image( output_dir.mkdir(parents=True, exist_ok=True)
md_content, output_path = output_dir / output_image
output_image, if html_content and html_content.strip():
max_retries=max(1, self.image_render_retries), await asyncio.wait_for(
render_timeout_seconds=max(10, self.image_render_timeout_seconds), html_to_image(html_content, str(output_path)),
html_timeout_seconds=min(30, max(10, self.image_render_timeout_seconds)), timeout=total_timeout,
), )
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)) await bot.send_image_message(target, Path(image_path))
except Exception as e: except Exception as e:
self.LOG.error(f"[{self.name}] 菜单图片发送失败: {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_text = self.get_enabled_features(roomid if roomid else sender)
menu_markdown = self._build_feature_status_markdown(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( await self._send_menu_content(
bot=bot, bot=bot,
target=target, target=target,
@@ -328,6 +584,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke=revoke, revoke=revoke,
text_content=menu_text, text_content=menu_text,
markdown_content=menu_markdown, markdown_content=menu_markdown,
html_content=menu_html,
revoke_seconds=90, revoke_seconds=90,
) )
return True, "显示功能菜单" return True, "显示功能菜单"
@@ -341,6 +598,7 @@ class RobotMenuPlugin(MessagePluginInterface):
# 显示所有功能状态 # 显示所有功能状态
status_text = self.display_menu_status(roomid if roomid else sender) status_text = self.display_menu_status(roomid if roomid else sender)
status_markdown = self._build_feature_status_markdown(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( await self._send_menu_content(
bot=bot, bot=bot,
target=target, target=target,
@@ -348,6 +606,7 @@ class RobotMenuPlugin(MessagePluginInterface):
revoke=revoke, revoke=revoke,
text_content=status_text, text_content=status_text,
markdown_content=status_markdown, markdown_content=status_markdown,
html_content=status_html,
revoke_seconds=90, revoke_seconds=90,
) )
return True, "显示功能状态" return True, "显示功能状态"