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]
|
[RobotMenu]
|
||||||
enable = true
|
enable = true
|
||||||
command = ["菜单", "功能菜单"]
|
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 = """
|
command-format = """
|
||||||
📝功能菜单指令:
|
📝功能菜单指令:
|
||||||
菜单 - 显示功能菜单
|
菜单 - 显示功能菜单
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
|
||||||
from loguru import logger
|
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 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.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
|
||||||
@@ -70,6 +74,25 @@ class RobotMenuPlugin(MessagePluginInterface):
|
|||||||
self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"])
|
self._commands = self._config.get("RobotMenu", {}).get("command", ["菜单", "功能"])
|
||||||
self.command_format = self._config.get("RobotMenu", {}).get("command-format", "菜单 功能名")
|
self.command_format = self._config.get("RobotMenu", {}).get("command-format", "菜单 功能名")
|
||||||
self.enable = self._config.get("RobotMenu", {}).get("enable", True)
|
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}")
|
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
|
||||||
return True
|
return True
|
||||||
@@ -151,6 +174,110 @@ class RobotMenuPlugin(MessagePluginInterface):
|
|||||||
return "当前没有启用机器人的群组"
|
return "当前没有启用机器人的群组"
|
||||||
return "\n".join(group_list)
|
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):
|
def at_list(self, xml):
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(xml)
|
root = ET.fromstring(xml)
|
||||||
@@ -193,9 +320,16 @@ class RobotMenuPlugin(MessagePluginInterface):
|
|||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
# 显示功能菜单
|
# 显示功能菜单
|
||||||
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)
|
||||||
client_msg_id, create_time, new_msg_id = await bot.send_text_message(target, menu_text, sender)
|
await self._send_menu_content(
|
||||||
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90)
|
bot=bot,
|
||||||
|
target=target,
|
||||||
|
sender=sender,
|
||||||
|
revoke=revoke,
|
||||||
|
text_content=menu_text,
|
||||||
|
markdown_content=menu_markdown,
|
||||||
|
revoke_seconds=90,
|
||||||
|
)
|
||||||
return True, "显示功能菜单"
|
return True, "显示功能菜单"
|
||||||
|
|
||||||
# 提取命令名
|
# 提取命令名
|
||||||
@@ -206,8 +340,16 @@ class RobotMenuPlugin(MessagePluginInterface):
|
|||||||
if cmd_name.upper() == "状态":
|
if cmd_name.upper() == "状态":
|
||||||
# 显示所有功能状态
|
# 显示所有功能状态
|
||||||
status_text = self.display_menu_status(roomid if roomid else sender)
|
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)
|
status_markdown = self._build_feature_status_markdown(roomid if roomid else sender)
|
||||||
revoke.add_message_to_revoke(target, client_msg_id, create_time, new_msg_id, 90)
|
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, "显示功能状态"
|
return True, "显示功能状态"
|
||||||
|
|
||||||
# 处理群列表命令
|
# 处理群列表命令
|
||||||
|
|||||||
Reference in New Issue
Block a user