Files
abot/plugins/robot_menu/menu_render_tool.py

444 lines
18 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
@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. 纯自动/定时类功能不在图片主体展示,避免干扰普通用户阅读。
"""
result: list[Feature] = []
for feature in Feature:
_, cmd_hint = self._split_feature_description(feature.description)
if self._extract_command_tokens(cmd_hint):
result.append(feature)
return result
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 _get_feature_command_examples(self, feature: Feature) -> str:
"""返回单个功能在卡片中的紧凑使用说明(不包含管理员内容)。"""
_, cmd_hint = self._split_feature_description(feature.description)
tokens = self._extract_command_tokens(cmd_hint)
if not tokens:
return "自动/定时触发,无需发送命令"
rows = [f"<span class='line-main'>指令:<code>{html.escape(tokens[0])}</code></span>"]
if len(tokens) > 1:
alias_text = " / ".join([f"<code>{html.escape(t)}</code>" for t in tokens[1:4]])
rows.append(f"<span class='line-sub'>别名:{alias_text}</span>")
return "<br>".join(rows)
def build_feature_status_html(self, group_id: str) -> str:
"""构建菜单 HTML自定义样式不复用 md2image 默认样式)。"""
# UI 设计方向(依据用户指定 skill
# 1. 采用“精致极简 + 信息密度高”的纵向单列排版;
# 2. 仅展示用户可直接触发的功能,保证篇幅短、理解快;
# 3. 使用克制但有辨识度的色彩/字体组合,避免通用模板感。
user_features = self._iter_user_command_features()
feature_cards = []
for feature in user_features:
title, _ = self._split_feature_description(feature.description)
command_examples = self._get_feature_command_examples(feature)
status = GroupBotManager.get_group_permission(group_id, feature)
is_enabled = status == PermissionStatus.ENABLED
status_text = "开启" if is_enabled else "关闭"
status_class = "status-on" if is_enabled else "status-off"
# 为纵向卡片增加轻量色彩分组,提升视觉节奏感,避免纯白卡片过于单调。
try:
tone_idx = int(feature.value) % 4
except Exception:
tone_idx = 0
feature_cards.append(
f"""
<section class="feature-card tone-{tone_idx}">
<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="status-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: #0b4f93;
--brand-soft: #e9f2ff;
--accent: #f59e0b;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
padding: 10px;
font-family: "Source Han Sans SC", "PingFang SC", "Noto Sans CJK SC", sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(11,79,147,0.08), transparent 36%),
linear-gradient(180deg, #edf3f9 0%, #f7fbff 100%);
color: var(--text);
}}
.page {{
width: 660px;
margin: 0 auto;
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 16px rgba(20, 47, 76, 0.08);
}}
.hero {{
padding: 12px 14px 10px 14px;
background:
linear-gradient(130deg, rgba(255,255,255,0.12), rgba(255,255,255,0) 45%),
linear-gradient(135deg, #0b4f93 0%, #256db3 58%, #3181c9 100%);
color: #fff;
}}
.hero h1 {{
margin: 0 0 4px 0;
font-size: 20px;
font-family: "Source Han Serif SC", "STSong", serif;
letter-spacing: .4px;
}}
.hero p {{ margin: 1px 0; font-size: 11px; opacity: .95; }}
.content {{ padding: 8px 10px 10px 10px; }}
.block {{
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 10px;
margin-bottom: 8px;
background: #fff;
}}
.block h2 {{
margin: 0 0 5px 0;
font-size: 13px;
color: #12395f;
display: flex;
align-items: center;
gap: 6px;
}}
.block h2::before {{
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
display: inline-block;
}}
.block ul {{
margin: 0;
padding-left: 16px;
color: var(--muted);
line-height: 1.45;
font-size: 11px;
}}
/* 纵向单列流式布局:避免双列模式下因内容长度差异产生空白洞。 */
.feature-list {{ display: flex; flex-direction: column; gap: 8px; }}
.feature-card {{
border: 1px solid #d6e4f3;
border-left-width: 4px;
border-radius: 9px;
padding: 7px 9px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow: 0 3px 8px rgba(16, 64, 125, 0.05);
}}
.feature-card.tone-0 {{ border-left-color: #2f8cff; }}
.feature-card.tone-1 {{ border-left-color: #10b981; }}
.feature-card.tone-2 {{ border-left-color: #f59e0b; }}
.feature-card.tone-3 {{ border-left-color: #ef4444; }}
.feature-top {{ display: flex; align-items: center; gap: 8px; }}
.feature-index {{
min-width: 26px;
text-align: center;
font-weight: 700;
color: #0f5fb7;
background: var(--brand-soft);
border: 1px solid #cfe1fb;
border-radius: 6px;
padding: 2px 4px;
font-size: 10px;
}}
.feature-meta {{ flex: 1; }}
.feature-meta h3 {{ margin: 0 0 1px 0; font-size: 12px; }}
.feature-key {{ margin: 0; font-size: 10px; color: var(--muted); }}
.status-badge {{
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}}
.status-on {{
color: #0f7a4f;
background: #e9f9f1;
border-color: #bde8d2;
}}
.status-off {{
color: #9f2f2f;
background: #feefef;
border-color: #f3c7c7;
}}
.feature-body {{
margin-top: 5px;
padding: 5px 7px;
border-radius: 7px;
background: #f7fbff;
border: 1px dashed #d2e4f7;
}}
.feature-body p {{ margin: 0; font-size: 10.5px; color: #3d5870; line-height: 1.45; }}
.line-main {{ color: #1f3f62; }}
.line-sub {{ color: #5a738a; }}
code {{
background: #eef6ff;
border: 1px solid #d2e6ff;
color: #12539a;
padding: 0 5px;
border-radius: 5px;
font-size: 10px;
}}
</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>
<div class="feature-list">
<section class="feature-card tone-0">
<div class="feature-top">
<div class="feature-index">A</div>
<div class="feature-meta">
<h3>查看菜单</h3>
<p class="feature-key"><code>菜单</code></p>
</div>
</div>
<div class="feature-body">
<p><span class="line-main">发送一次即可查看全部功能卡片</span></p>
</div>
</section>
<section class="feature-card tone-1">
<div class="feature-top">
<div class="feature-index">B</div>
<div class="feature-meta">
<h3>展示范围</h3>
<p class="feature-key">用户直接可用指令</p>
</div>
</div>
<div class="feature-body">
<p><span class="line-main">当前共 {len(user_features)} 项</span></p>
</div>
</section>
</div>
</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)