feat(菜单): 支持菜单文本/图片可配置输出并接入md2image\n\n- 新增 RobotMenu.output_mode 配置,支持 text 与 image 两种菜单发送模式\n- 新增图片相关配置:image_fallback_to_text、image_render_timeout_seconds、image_render_retries\n- 菜单与菜单状态命令支持按配置走 md2image 生成图片后发送\n- 新增菜单 Markdown 生成逻辑,图片中展示每个插件的序号、功能键、状态、说明、指令信息\n- 图片发送失败时按配置决定是否回退文本,并补充失败提示与日志\n- 补充详细中文注释并保持原文本发送逻辑兼容
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
[RobotMenu]
|
||||
enable = true
|
||||
command = ["菜单", "功能菜单"]
|
||||
# 菜单输出模式:
|
||||
# - text:发送文本菜单(历史行为)
|
||||
# - image:先用 md2image 将 Markdown 渲染为图片后发送
|
||||
output_mode = "image"
|
||||
# 图片生成失败时是否回退文本菜单:
|
||||
# - false:严格按图片模式,不发送完整菜单文本
|
||||
# - true:优先保证可达,失败后改发文本
|
||||
image_fallback_to_text = false
|
||||
# md2image 渲染参数:可按服务器性能调整
|
||||
image_render_timeout_seconds = 45
|
||||
image_render_retries = 1
|
||||
command-format = """
|
||||
📝功能菜单指令:
|
||||
菜单 - 显示功能菜单
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
@@ -8,6 +11,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.revoke.message_auto_revoke import MessageAutoRevoke
|
||||
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager
|
||||
from utils.wechat.contact_manager import ContactManager
|
||||
@@ -70,6 +74,25 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"])
|
||||
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")
|
||||
)
|
||||
# 图片生成失败时是否回退到文本:
|
||||
# - True:优先保证消息可达;
|
||||
# - False:严格按“只发图片”执行(失败时仅提示失败原因)。
|
||||
self.image_fallback_to_text = bool(
|
||||
self._config.get("RobotMenu", {}).get("image_fallback_to_text", False)
|
||||
)
|
||||
# 菜单图片渲染超时与重试配置,避免极端环境下长时间阻塞消息处理协程。
|
||||
self.image_render_timeout_seconds = int(
|
||||
self._config.get("RobotMenu", {}).get("image_render_timeout_seconds", 45)
|
||||
)
|
||||
self.image_render_retries = int(
|
||||
self._config.get("RobotMenu", {}).get("image_render_retries", 1)
|
||||
)
|
||||
|
||||
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
|
||||
return True
|
||||
@@ -151,6 +174,110 @@ 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)
|
||||
|
||||
async def _send_menu_content(
|
||||
self,
|
||||
bot: WechatAPIClient,
|
||||
target: str,
|
||||
sender: str,
|
||||
revoke: MessageAutoRevoke,
|
||||
text_content: str,
|
||||
markdown_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
|
||||
|
||||
# 图片模式:将菜单 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,
|
||||
)
|
||||
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)
|
||||
@@ -193,9 +320,16 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
if len(parts) == 1:
|
||||
# 显示功能菜单
|
||||
menu_text = self.get_enabled_features(roomid if roomid else sender)
|
||||
|
||||
client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, menu_text, sender)
|
||||
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90)
|
||||
menu_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
|
||||
await self._send_menu_content(
|
||||
bot=bot,
|
||||
target=target,
|
||||
sender=sender,
|
||||
revoke=revoke,
|
||||
text_content=menu_text,
|
||||
markdown_content=menu_markdown,
|
||||
revoke_seconds=90,
|
||||
)
|
||||
return True, "显示功能菜单"
|
||||
|
||||
# 提取命令名
|
||||
@@ -206,8 +340,16 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
if cmd_name.upper() == "状态":
|
||||
# 显示所有功能状态
|
||||
status_text = self.display_menu_status(roomid if roomid else sender)
|
||||
client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, status_text, sender)
|
||||
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90)
|
||||
status_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
|
||||
await self._send_menu_content(
|
||||
bot=bot,
|
||||
target=target,
|
||||
sender=sender,
|
||||
revoke=revoke,
|
||||
text_content=status_text,
|
||||
markdown_content=status_markdown,
|
||||
revoke_seconds=90,
|
||||
)
|
||||
return True, "显示功能状态"
|
||||
|
||||
# 处理群列表命令
|
||||
|
||||
Reference in New Issue
Block a user