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 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"触发:自动/定时触发(无直接聊天指令)<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(
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, "显示功能状态"