refactor(菜单): 抽离菜单渲染工具为独立模块便于维护\n\n- 新增 plugins/robot_menu/menu_render_tool.py,封装菜单渲染与图片发送能力\n- 将输出模式归一化、Markdown/HTML 生成、自定义样式模板、发送与回退策略统一迁移到工具类\n- main.py 仅保留菜单业务流程与命令处理,改为调用独立工具,降低主文件复杂度\n- 使用相对导入接入新工具模块,减少路径耦合风险\n- 补充详细中文注释,说明拆分目的与关键设计
This commit is contained in:
@@ -1,8 +1,3 @@
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
@@ -11,9 +6,9 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
from base.plugin_common.plugin_interface import PluginStatus
|
||||
from .menu_render_tool import RobotMenuRenderTool
|
||||
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, 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
|
||||
@@ -77,11 +72,8 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
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")
|
||||
)
|
||||
# 这里不在主插件内实现渲染细节,而是交给独立工具模块处理。
|
||||
output_mode = self._config.get("RobotMenu", {}).get("output_mode", "text")
|
||||
# 图片生成失败时是否回退到文本:
|
||||
# - True:优先保证消息可达;
|
||||
# - False:严格按“只发图片”执行(失败时仅提示失败原因)。
|
||||
@@ -95,6 +87,14 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
self.image_render_retries = int(
|
||||
self._config.get("RobotMenu", {}).get("image_render_retries", 1)
|
||||
)
|
||||
# 初始化“菜单渲染工具”,后续菜单图片与文本发送统一由该工具负责。
|
||||
self.menu_renderer = RobotMenuRenderTool(
|
||||
output_mode=output_mode,
|
||||
image_fallback_to_text=self.image_fallback_to_text,
|
||||
image_render_timeout_seconds=self.image_render_timeout_seconds,
|
||||
image_render_retries=self.image_render_retries,
|
||||
log=self.LOG,
|
||||
)
|
||||
|
||||
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
|
||||
return True
|
||||
@@ -176,363 +176,6 @@ 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)
|
||||
|
||||
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,
|
||||
target: str,
|
||||
sender: str,
|
||||
revoke: MessageAutoRevoke,
|
||||
text_content: str,
|
||||
markdown_content: Optional[str] = None,
|
||||
html_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
|
||||
|
||||
# 图片模式:优先使用“自定义 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)
|
||||
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}")
|
||||
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)
|
||||
@@ -575,9 +218,9 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
if len(parts) == 1:
|
||||
# 显示功能菜单
|
||||
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(
|
||||
menu_markdown = self.menu_renderer.build_feature_status_markdown(roomid if roomid else sender)
|
||||
menu_html = self.menu_renderer.build_feature_status_html(roomid if roomid else sender)
|
||||
await self.menu_renderer.send_menu_content(
|
||||
bot=bot,
|
||||
target=target,
|
||||
sender=sender,
|
||||
@@ -597,9 +240,9 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
if cmd_name.upper() == "状态":
|
||||
# 显示所有功能状态
|
||||
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(
|
||||
status_markdown = self.menu_renderer.build_feature_status_markdown(roomid if roomid else sender)
|
||||
status_html = self.menu_renderer.build_feature_status_html(roomid if roomid else sender)
|
||||
await self.menu_renderer.send_menu_content(
|
||||
bot=bot,
|
||||
target=target,
|
||||
sender=sender,
|
||||
|
||||
323
plugins/robot_menu/menu_render_tool.py
Normal file
323
plugins/robot_menu/menu_render_tool.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
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, GroupBotManager, PermissionStatus
|
||||
from wechat_ipad import WechatAPIClient
|
||||
|
||||
|
||||
class RobotMenuRenderTool:
|
||||
"""机器人菜单渲染工具。
|
||||
|
||||
设计目标:
|
||||
1. 将“菜单排版 + 图片渲染 + 发送策略”从主插件拆出,降低 main.py 维护成本;
|
||||
2. 统一菜单文本/图片输出行为,保证菜单与状态页展示一致;
|
||||
3. 对外只暴露清晰方法,主插件只负责“取业务数据 + 调用工具”。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_mode: Any,
|
||||
image_fallback_to_text: bool,
|
||||
image_render_timeout_seconds: int,
|
||||
image_render_retries: int,
|
||||
log=default_logger,
|
||||
):
|
||||
# 输出模式:支持 text / image,非法值自动归一化为 text。
|
||||
self.output_mode = self.normalize_output_mode(output_mode)
|
||||
# 图片失败时是否回退文本。
|
||||
self.image_fallback_to_text = bool(image_fallback_to_text)
|
||||
# 渲染超时与重试参数,统一集中在工具层处理。
|
||||
self.image_render_timeout_seconds = int(image_render_timeout_seconds)
|
||||
self.image_render_retries = int(image_render_retries)
|
||||
# 注入日志对象,便于主插件统一控制日志风格与输出目标。
|
||||
self.log = log or default_logger
|
||||
|
||||
@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. 描述中 `[]` 内的文本作为“触发说明”;
|
||||
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(作为图片渲染兜底或文本调试来源)。"""
|
||||
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)
|
||||
|
||||
def _get_feature_command_examples(self, feature: Feature) -> str:
|
||||
"""返回单个功能在菜单卡片中的详细指令文案。"""
|
||||
_, 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 默认样式)。"""
|
||||
feature_cards = []
|
||||
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 {{
|
||||
--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,
|
||||
target: str,
|
||||
sender: str,
|
||||
revoke: MessageAutoRevoke,
|
||||
text_content: str,
|
||||
markdown_content: Optional[str] = None,
|
||||
html_content: Optional[str] = None,
|
||||
revoke_seconds: int = 90,
|
||||
) -> None:
|
||||
"""按配置发送菜单内容(文本或图片)。"""
|
||||
if self.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
|
||||
|
||||
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)
|
||||
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"[机器人菜单渲染工具] 菜单图片发送失败: {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)
|
||||
Reference in New Issue
Block a user