452 lines
18 KiB
Python
452 lines
18 KiB
Python
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 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(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}}
|
||
.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: 9px;
|
||
}}
|
||
.feature-meta {{ flex: 1; }}
|
||
.feature-meta h3 {{
|
||
margin: 0 0 2px 0;
|
||
font-size: 15px;
|
||
font-weight: 300;
|
||
line-height: 1.25;
|
||
letter-spacing: .1px;
|
||
color: #000;
|
||
}}
|
||
.feature-key {{ margin: 0; font-size: 9px; color: var(--muted); }}
|
||
.status-badge {{
|
||
font-size: 9px;
|
||
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: 11px; 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: 9px;
|
||
}}
|
||
.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);
|
||
}}
|
||
/* 这里故意不保留响应式列数切换:
|
||
用户需求是“用于生成图片”,统一固定三列更可控。 */
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header class="hero">
|
||
<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)
|