增强机器人菜单自动指令清单能力

This commit is contained in:
liuwei
2026-04-30 17:41:41 +08:00
parent 369b74e834
commit 5feee880ed
3 changed files with 303 additions and 1 deletions

View File

@@ -581,6 +581,12 @@
- 降低普通用户与管理员的使用门槛
当前进展:
- 第一阶段已完成:`菜单 指令清单 / 功能清单 / 命令清单 / 帮助` 已改为基于运行中插件快照自动生成
- 第一阶段已完成:指令清单已按当前群真实可用状态过滤,管理员可额外看到未启用命令与管理命令
- 后续可继续补充后台命令索引页、插件触发示例模板与更细粒度的分类标签
建议内容:
- 自动生成按插件分类的帮助菜单

View File

@@ -20,7 +20,7 @@ class RobotMenuPlugin(MessagePluginInterface):
# 功能权限常量
FEATURE_KEY = "ROBOT_MENU"
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 - 显示功能菜单 | 菜单 状态 - 显示功能状态]"
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 | 菜单 状态 | 菜单 指令清单]"
@property
def name(self) -> str:
@@ -263,6 +263,31 @@ class RobotMenuPlugin(MessagePluginInterface):
)
return True, "显示功能状态"
if cmd_name in {"指令清单", "功能清单", "命令清单", "帮助"}:
# 指令清单改为直接从插件快照自动生成:
# 1. 展示当前群“真实可用”的命令,而不是手工维护的固定文案;
# 2. 管理员额外看到未启用项与管理命令,普通用户只看到能直接用的内容;
# 3. 这样后续新增/删除插件后,菜单无需手动同步修改。
command_catalog_text = self.menu_renderer.build_command_catalog_text(
roomid if roomid else sender,
sender,
)
command_catalog_markdown = self.menu_renderer.build_command_catalog_markdown(
roomid if roomid else sender,
sender,
)
await self.menu_renderer.send_menu_content(
bot=bot,
target=target,
sender=sender,
revoke=revoke,
text_content=command_catalog_text,
markdown_content=command_catalog_markdown,
html_content="",
revoke_seconds=120,
)
return True, "显示指令清单"
# 处理群列表命令
if cmd_name.upper() == "群列表":
group_list_text = self.get_group_list()

View File

@@ -7,6 +7,7 @@ from typing import Any, Optional, Tuple
from loguru import logger as default_logger
from base.plugin_common.plugin_manager import PluginManager
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
@@ -189,6 +190,276 @@ class RobotMenuRenderTool:
},
)
@staticmethod
def _get_plugin_manager() -> PluginManager:
"""获取当前运行中的插件管理器单例。"""
return PluginManager.get_instance()
@staticmethod
def _resolve_snapshot_group_status(snapshot: dict, group_id: str) -> dict:
"""解析插件在当前群里的可用状态。
规则说明:
1. 插件必须先处于 RUNNING才可能被认为“可用”
2. 若插件支持群级开关,则继续读取该群的 feature 权限;
3. 若插件没有群级开关,则视为“运行即全局可用”。
"""
normalized_snapshot = dict(snapshot or {})
status = str(normalized_snapshot.get("status") or "").strip().upper()
supports_group_switch = bool(normalized_snapshot.get("supports_group_switch"))
feature_key = str(normalized_snapshot.get("feature_key") or "").strip()
if status != "RUNNING":
return {
"available": False,
"reason": "插件未运行",
"reason_code": "plugin_not_running",
}
if not group_id or not supports_group_switch or not feature_key:
return {
"available": True,
"reason": "全局可用",
"reason_code": "global_available",
}
feature = Feature.get_feature(feature_key)
if feature is None:
return {
"available": True,
"reason": "未绑定群级开关,按运行中处理",
"reason_code": "feature_not_registered",
}
permission = GroupBotManager.get_group_permission(group_id, feature)
if permission == PermissionStatus.ENABLED:
return {
"available": True,
"reason": "本群已启用",
"reason_code": "group_enabled",
}
return {
"available": False,
"reason": "本群未启用",
"reason_code": "group_disabled",
}
@staticmethod
def _format_plugin_command(example_command: str, command_prefix: str) -> str:
"""把插件命令和前缀拼成最终展示文本。"""
prefix = str(command_prefix or "").strip()
command = str(example_command or "").strip()
if not prefix:
return command
return f"{prefix}{command}"
def _build_plugin_command_entry(self, snapshot: dict, group_id: str) -> Optional[dict]:
"""把插件快照转换为菜单可展示的命令项。"""
normalized_snapshot = dict(snapshot or {})
commands = list(normalized_snapshot.get("commands", []) or [])
plugin_types = list(normalized_snapshot.get("plugin_types", []) or [])
if not commands and "scheduled" not in plugin_types:
return None
availability = self._resolve_snapshot_group_status(normalized_snapshot, group_id)
command_prefix = str(normalized_snapshot.get("command_prefix") or "").strip()
primary_command = self._format_plugin_command(commands[0], command_prefix) if commands else ""
alias_commands = [
self._format_plugin_command(command_text, command_prefix)
for command_text in commands[1:4]
if str(command_text or "").strip()
]
if "message" in plugin_types:
category = "message"
category_label = "消息指令"
elif "scheduled" in plugin_types:
category = "scheduled"
category_label = "自动任务"
else:
category = "generic"
category_label = "通用能力"
return {
"name": str(normalized_snapshot.get("name") or "").strip(),
"module_name": str(normalized_snapshot.get("module_name") or "").strip(),
"description": str(normalized_snapshot.get("description") or "").strip() or "暂无描述",
"category": category,
"category_label": category_label,
"commands": commands,
"primary_command": primary_command,
"alias_commands": alias_commands,
"supports_group_switch": bool(normalized_snapshot.get("supports_group_switch")),
"feature_key": str(normalized_snapshot.get("feature_key") or "").strip(),
"available": bool(availability.get("available")),
"availability_reason": str(availability.get("reason") or "").strip(),
"availability_code": str(availability.get("reason_code") or "").strip(),
"status_label": str(normalized_snapshot.get("status_label") or "").strip(),
}
def _collect_command_catalog(self, group_id: str, requester_id: str) -> dict:
"""采集当前群和当前身份视角下的命令清单。
输出结构分三层:
1. 普通用户可直接用的命令;
2. 自动/定时能力;
3. 管理员附加能力与未启用项。
"""
plugin_manager = self._get_plugin_manager()
snapshots = plugin_manager.get_plugin_snapshots()
is_admin = bool(GroupBotManager.is_admin_for_group(requester_id, group_id)) if group_id else bool(GroupBotManager.is_admin(requester_id))
available_manual = []
available_auto = []
unavailable_manual = []
for snapshot in snapshots:
entry = self._build_plugin_command_entry(snapshot, group_id)
if not entry:
continue
if entry["category"] == "scheduled":
if entry["available"]:
available_auto.append(entry)
continue
if entry["available"]:
available_manual.append(entry)
else:
unavailable_manual.append(entry)
available_manual.sort(key=lambda item: (item["category"], item["name"], item["primary_command"]))
available_auto.sort(key=lambda item: (item["name"], item["primary_command"]))
unavailable_manual.sort(key=lambda item: (item["availability_code"], item["name"]))
admin_commands = []
if is_admin:
admin_commands = [
{"title": "查看功能状态", "example": "菜单 状态", "description": "查看当前群所有功能开关状态"},
{"title": "启用某个功能", "example": "菜单 启用 功能序号", "description": "按菜单序号启用某项功能"},
{"title": "关闭某个功能", "example": "菜单 关闭 功能序号", "description": "按菜单序号关闭某项功能"},
{"title": "查看群管理员", "example": "菜单 管理员 列表", "description": "查看当前群管理员清单"},
{"title": "添加群管理员", "example": "菜单 管理员 添加 @某人", "description": "把某个群成员加入本群管理员"},
{"title": "删除群管理员", "example": "菜单 管理员 删除 @某人", "description": "移除某个群管理员"},
]
if GroupBotManager.is_admin(requester_id):
admin_commands.append(
{"title": "查看托管群列表", "example": "菜单 群列表", "description": "查看所有已启用机器人的群"}
)
return {
"group_id": str(group_id or "").strip(),
"requester_id": str(requester_id or "").strip(),
"is_admin": is_admin,
"available_manual": available_manual,
"available_auto": available_auto,
"unavailable_manual": unavailable_manual,
"admin_commands": admin_commands,
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
def build_command_catalog_text(self, group_id: str, requester_id: str) -> str:
"""构建适合直接发送给用户的文本版命令清单。"""
catalog = self._collect_command_catalog(group_id, requester_id)
lines = [
"📚 当前群指令清单",
f"群ID{catalog['group_id'] or '私聊'}",
f"生成时间:{catalog['generated_at']}",
"",
"一、当前可直接使用的命令",
]
if catalog["available_manual"]:
for item in catalog["available_manual"]:
lines.append(f"{item['name']}{item['description']}")
if item["primary_command"]:
lines.append(f"主指令:{item['primary_command']}")
if item["alias_commands"]:
lines.append(f"别名:{' / '.join(item['alias_commands'])}")
lines.append("")
else:
lines.append("当前没有可直接使用的手动命令")
lines.append("")
lines.append("二、自动/定时能力")
if catalog["available_auto"]:
for item in catalog["available_auto"]:
lines.append(f"{item['name']}{item['description']}")
lines.append("触发方式:自动或定时运行")
lines.append("")
else:
lines.append("当前没有已启用的自动能力")
lines.append("")
if catalog["is_admin"]:
lines.append("三、管理员额外可见")
if catalog["unavailable_manual"]:
lines.append("未启用或暂不可用命令:")
for item in catalog["unavailable_manual"]:
primary = item["primary_command"] or "无手动指令"
lines.append(f"- {item['name']}{primary}{item['availability_reason']}")
lines.append("")
else:
lines.append("当前没有未启用的命令项")
lines.append("")
lines.append("管理命令:")
for item in catalog["admin_commands"]:
lines.append(f"- {item['example']}{item['description']}")
lines.append("")
lines.append("提示:发送“菜单”查看功能开关;发送“菜单 状态”查看本群功能状态。")
return "\n".join(lines).strip()
def build_command_catalog_markdown(self, group_id: str, requester_id: str) -> str:
"""构建适合图片渲染的 Markdown 版指令清单。"""
catalog = self._collect_command_catalog(group_id, requester_id)
lines = [
"# 机器人指令清单",
"",
f"- 目标:`{catalog['group_id'] or '私聊'}`",
f"- 生成时间:`{catalog['generated_at']}`",
"",
"## 当前可直接使用的命令",
]
if catalog["available_manual"]:
for item in catalog["available_manual"]:
lines.append(f"### {item['name']}")
lines.append(f"- 说明:{item['description']}")
if item["primary_command"]:
lines.append(f"- 主指令:`{item['primary_command']}`")
if item["alias_commands"]:
alias_text = " / ".join(f"`{alias}`" for alias in item["alias_commands"])
lines.append(f"- 别名:{alias_text}")
lines.append("")
else:
lines.append("- 当前没有可直接使用的手动命令")
lines.append("")
lines.append("## 自动/定时能力")
if catalog["available_auto"]:
for item in catalog["available_auto"]:
lines.append(f"- **{item['name']}**{item['description']}")
else:
lines.append("- 当前没有已启用的自动能力")
lines.append("")
if catalog["is_admin"]:
lines.append("## 管理员额外可见")
if catalog["unavailable_manual"]:
lines.append("### 未启用或暂不可用命令")
for item in catalog["unavailable_manual"]:
primary = item["primary_command"] or "无手动指令"
lines.append(f"- **{item['name']}**`{primary}`{item['availability_reason']}")
lines.append("")
lines.append("### 管理命令")
for item in catalog["admin_commands"]:
lines.append(f"- `{item['example']}`{item['description']}")
lines.append("")
lines.append("> 提示:发送 `菜单` 查看功能开关;发送 `菜单 状态` 查看本群功能状态。")
return "\n".join(lines)
async def send_menu_content(
self,
bot: WechatAPIClient,