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"触发:自动/定时触发(无直接聊天指令)
{manage_hint}" return f"触发:{html.escape(cmd_hint)}
{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"""
#{feature.value}

{html.escape(title)}

功能键:{html.escape(feature.name)}

{status_text}

{command_examples}

""" ) now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) return f"""

机器人功能菜单

目标群/会话:{html.escape(group_id)}

生成时间:{now_text}

基础命令

  • 菜单:查看完整功能菜单(状态 + 详细指令)
  • 菜单 状态:查看所有功能当前启用状态
  • 菜单 群列表:查看已启用群机器人的群组清单

管理员命令

  • 菜单 启用 序号 / 菜单 关闭 序号
  • 菜单 启用 功能键 / 菜单 关闭 功能键
  • 菜单 管理员 添加 wxid/昵称菜单 管理员 删除 wxid/昵称菜单 管理员 列表

功能明细

{''.join(feature_cards)}
""" 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)