Files
abot/plugins/robot_menu/menu_render_tool.py

465 lines
19 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. 全量展示 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 _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:
"""构建 PlayStation 风格菜单 HTML黑-白-蓝三段式)。"""
# 设计实现说明(基于 DESIGN-playstation.md
# 1. 三段式表面:黑色英雄区 -> 白色内容区 -> 蓝色底部;
# 2. 主色锚点固定为 PlayStation Blue #0070cc交互强调色为 #1eaedb
# 3. 组件采用圆角胶囊/卡片体系,保持信息密度与可读性平衡。
user_features = self._iter_user_command_features()
feature_cards = []
enabled_count = 0
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
if is_enabled:
enabled_count += 1
status_text = "开启" if is_enabled else "关闭"
status_class = "status-on" if is_enabled else "status-off"
feature_cards.append(
f"""
<section class="feature-card">
<div class="feature-top">
<div class="feature-index">{feature.value:02d}</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 {{
--ps-blue: #0070cc;
--ps-cyan: #1eaedb;
--ps-black: #000000;
--ps-white: #ffffff;
--ink: #1f1f1f;
--muted: #6b6b6b;
--line: #f3f3f3;
--ok: #0f7a4f;
--ok-bg: #e9f9f1;
--off: #9f2f2f;
--off-bg: #feefef;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
padding: 16px;
font-family: "PlayStation SST", "SST", "PingFang SC", "Noto Sans CJK SC", sans-serif;
background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 100%);
color: var(--ink);
}}
.page {{
width: 980px;
margin: 0 auto;
background: #fff;
border: 1px solid #dfe6ef;
border-radius: 24px;
overflow: hidden;
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
}}
.hero {{
padding: 28px 30px 24px 30px;
background: linear-gradient(180deg, #121314 0%, #000000 100%);
color: var(--ps-white);
}}
.hero-kicker {{
display: inline-block;
font-size: 12px;
letter-spacing: .3px;
border-radius: 999px;
padding: 4px 10px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.18);
margin-bottom: 12px;
}}
.hero h1 {{
margin: 0 0 8px 0;
font-size: 44px;
font-weight: 300;
line-height: 1.25;
letter-spacing: .1px;
}}
.hero-sub {{
margin: 0;
font-size: 18px;
line-height: 1.5;
color: rgba(255,255,255,.86);
}}
.hero-meta {{
margin-top: 14px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}}
.meta-pill {{
padding: 6px 12px;
border-radius: 999px;
font-size: 14px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.16);
}}
.meta-pill strong {{ font-weight: 700; }}
.content {{
padding: 22px 24px 20px 24px;
background: linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%);
}}
.block {{
border: 1px solid var(--line);
border-radius: 20px;
padding: 16px 18px;
margin-bottom: 14px;
background: #fff;
box-shadow: rgba(0, 0, 0, 0.06) 0 5px 9px 0;
}}
.block h2 {{
margin: 0;
font-size: 22px;
font-weight: 300;
line-height: 1.25;
letter-spacing: .1px;
color: #000;
}}
.feature-list {{
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}}
.feature-card {{
border: 1px solid #e6edf5;
border-radius: 19px;
padding: 12px 13px;
background: #fff;
box-shadow: rgba(0, 0, 0, 0.08) 0 5px 9px 0;
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}}
.feature-card:hover {{
transform: scale(1.02);
border-color: var(--ps-cyan);
box-shadow: rgba(0, 0, 0, 0.16) 0 5px 9px 0;
}}
.feature-top {{ display: flex; align-items: center; gap: 8px; }}
.feature-index {{
min-width: 38px;
text-align: center;
font-weight: 700;
color: var(--ps-blue);
background: #eef6ff;
border: 1px solid #d3e8ff;
border-radius: 12px;
padding: 4px 6px;
font-size: 12px;
}}
.feature-meta {{ flex: 1; }}
.feature-meta h3 {{
margin: 0 0 2px 0;
font-size: 18px;
font-weight: 300;
line-height: 1.25;
letter-spacing: .1px;
color: #000;
}}
.feature-key {{ margin: 0; font-size: 12px; color: var(--muted); }}
.status-badge {{
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}}
.status-on {{
color: var(--ok);
background: var(--ok-bg);
border-color: #bde8d2;
}}
.status-off {{
color: var(--off);
background: var(--off-bg);
border-color: #f3c7c7;
}}
.feature-body {{
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fbff;
border: 1px dashed #d6e6f7;
}}
.feature-body p {{ margin: 0; font-size: 14px; color: #1f1f1f; line-height: 1.5; }}
.line-main {{ color: #1f1f1f; }}
.line-sub {{ color: #6b6b6b; }}
code {{
background: #eef6ff;
border: 1px solid #d2e6ff;
color: #0068bd;
padding: 1px 7px;
border-radius: 999px;
font-size: 12px;
}}
.footer {{
background: var(--ps-blue);
color: #fff;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}}
.footer a {{
color: #fff;
text-decoration: none;
border-bottom: 1px dashed rgba(255,255,255,.55);
}}
@media (max-width: 1024px) {{
.page {{ width: 100%; border-radius: 20px; }}
.hero h1 {{ font-size: 35px; }}
.feature-list {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<div class="page">
<header class="hero">
<div class="hero-kicker">PlayStation Style Interface</div>
<h1>机器人功能菜单</h1>
<p class="hero-sub">用户功能查看中心 · 直达命令与状态一屏可见</p>
<div class="hero-meta">
<span class="meta-pill">目标:<strong>{html.escape(group_id)}</strong></span>
<span class="meta-pill">功能总数:<strong>{len(user_features)}</strong></span>
<span class="meta-pill">已开启:<strong>{enabled_count}</strong></span>
<span class="meta-pill">生成时间:<strong>{now_text}</strong></span>
</div>
</header>
<main class="content">
<section class="block">
<h2>功能卡片</h2>
<div class="feature-list">
{''.join(feature_cards)}
</div>
</section>
</main>
<footer class="footer">
<span>PlayStation Blue UI · 功能查看体验重构</span>
<a href="javascript:void(0)">菜单</a>
</footer>
</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)