Files
abot/plugins/robot_menu/menu_render_tool.py

324 lines
14 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 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)