Files
abot/plugins/robot_menu/menu_render_tool.py

246 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
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 utils.html_template_renderer import HtmlTemplateRenderer
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,
image_template_path: str,
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
# 菜单图片模板路径(相对仓库根目录),支持仅改模板文件完成 UI 更新。
self.image_template_path = str(image_template_path or "").strip() or "plugins/robot_menu/templates/menu_cards.html"
self.template_renderer = HtmlTemplateRenderer()
@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
@staticmethod
def _extract_command_tokens(cmd_hint: str) -> list[str]:
"""从描述里的指令片段提取命令 token 列表。
规则:
1. 按 `|`、``、`,`、`/`、`、` 切分;
2. 去重并保序;
3. 清理多余空白,便于后续生成“如何使用”示例。
"""
raw = str(cmd_hint or "").strip()
if not raw or raw == "":
return []
parts = re.split(r"[|,/、]+", raw)
tokens: list[str] = []
for part in parts:
token = re.sub(r"\s+", " ", str(part or "").strip())
if token and token not in tokens:
tokens.append(token)
return tokens
def _build_feature_usage_lines(self, feature: Feature) -> list[str]:
"""构建单个功能的“怎么用”文案(纯用户视角,不含管理员控制语句)。"""
title, cmd_hint = self._split_feature_description(feature.description)
tokens = self._extract_command_tokens(cmd_hint)
lines = [f"{feature.value}. {title}"]
if not tokens:
lines.append(" 用法:自动/定时触发,无需手动指令")
return lines
# 第一行给出最短触发命令,后续补充可选别名,信息更丰富但仍保持紧凑。
lines.append(f" 指令:{tokens[0]}")
if len(tokens) > 1:
lines.append(f" 别名:{' / '.join(tokens[1:4])}")
# 给一个通用可替换参数提示,帮助用户快速落地命令输入。
if any(k in tokens[0] for k in ["@", "积分", "关键词", "日期", "功能名"]):
lines.append(" 提示:按指令中的占位词替换为实际内容后发送")
return lines
def _iter_user_command_features(self) -> list[Feature]:
"""返回菜单要展示的功能集合。
说明:
1. 按用户最新要求“不要管之前限制”,这里不再做仅指令功能过滤;
2. 全量展示 Feature保证用户在一张图里看到完整功能地图
3. 没有显式指令的功能,在卡片中标注为“自动/定时触发”。
"""
return list(Feature)
def build_feature_status_markdown(self, group_id: str) -> str:
"""构建菜单 Markdown全量功能 + 状态 + 指令/触发方式)。"""
user_features = self._iter_user_command_features()
lines = [
"# 机器人功能菜单",
"",
f"- 目标:`{group_id}`",
f"- 生成时间:`{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}`",
"",
"## 功能与指令",
]
for feature in user_features:
status = GroupBotManager.get_group_permission(group_id, feature)
status_text = "开启" if status == PermissionStatus.ENABLED else "关闭"
lines.extend(self._build_feature_usage_lines(feature))
lines.append(f" 状态:{status_text}")
lines.append("")
return "\n".join(lines)
def _build_feature_command_payload(self, feature: Feature) -> dict:
"""构建功能卡片中的命令展示数据。"""
_, cmd_hint = self._split_feature_description(feature.description)
tokens = self._extract_command_tokens(cmd_hint)
if not tokens:
return {
"is_auto": True,
"primary": "",
"aliases": [],
"tip": "自动/定时触发,无需发送命令",
}
return {
"is_auto": False,
"primary": tokens[0],
"aliases": tokens[1:4],
"tip": "",
}
def build_feature_status_html(self, group_id: str) -> str:
"""基于外部 HTML 模板构建菜单页面。"""
user_features = self._iter_user_command_features()
feature_cards = []
enabled_count = 0
for feature in user_features:
title, _ = self._split_feature_description(feature.description)
status = GroupBotManager.get_group_permission(group_id, feature)
is_enabled = status == PermissionStatus.ENABLED
if is_enabled:
enabled_count += 1
command_payload = self._build_feature_command_payload(feature)
feature_cards.append(
{
"index": int(feature.value),
"title": title,
"feature_key": str(feature.name),
"status_text": "开启" if is_enabled else "关闭",
"status_class": "status-on" if is_enabled else "status-off",
"command": command_payload,
}
)
now_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
return self.template_renderer.render(
self.image_template_path,
{
"group_id": str(group_id or ""),
"now_text": now_text,
"feature_total": len(user_features),
"enabled_count": enabled_count,
"feature_cards": 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)