mioku-plugin-help 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,12 +12,21 @@
12
12
  */
13
13
 
14
14
  import type { CommandRole, PluginHelp } from "mioku";
15
+ import { botConfig } from "mioki";
15
16
  import { escapeHtml } from "../utils";
16
17
  import { getHelpTheme, HELP_BACKGROUND_IMAGE_URL } from "../theme";
17
18
  import { getRenderableEntries } from "./intent";
18
19
  import { ROLE_CONFIG } from "./role-config";
19
20
  import type { HelpRenderableEntry } from "./types";
20
21
 
22
+ function resolvePrefixPlaceholder(cmd: string): string {
23
+ const value = String(cmd || "");
24
+ if (value.startsWith("?")) {
25
+ return `${botConfig.prefix ?? "#"}${value.slice(1)}`;
26
+ }
27
+ return value;
28
+ }
29
+
21
30
  function renderRoleBadge(
22
31
  role: CommandRole,
23
32
  isNightMode: boolean,
@@ -38,7 +47,7 @@ function renderPluginOverview(entry: HelpRenderableEntry): string {
38
47
  const commandTags = entry.commands
39
48
  .map(
40
49
  (command) =>
41
- `<span class="help-command-tag" title="${escapeHtml(command.desc || "")}" >${escapeHtml(command.cmd)}</span>`,
50
+ `<span class="help-command-tag" title="${escapeHtml(command.desc || "")}" >${escapeHtml(resolvePrefixPlaceholder(command.cmd))}</span>`,
42
51
  )
43
52
  .join("");
44
53
 
@@ -73,7 +82,7 @@ function renderPluginDetail(
73
82
  return `
74
83
  <div class="help-command">
75
84
  <div class="help-command__top">
76
- <div class="help-command__name">${escapeHtml(command.cmd)}</div>
85
+ <div class="help-command__name">${escapeHtml(resolvePrefixPlaceholder(command.cmd))}</div>
77
86
  ${roleBadge}
78
87
  </div>
79
88
  <div class="help-command__desc">${escapeHtml(command.desc || "")}</div>
@@ -109,6 +118,9 @@ function renderPluginDetail(
109
118
  * Build the full help image HTML. `targetPluginName` switches the
110
119
  * renderer to detail mode for that plugin (no-op if the name doesn't
111
120
  * match anything in `helpMap`).
121
+ *
122
+ * `viewerRole` filters out commands the requesting user can't invoke,
123
+ * so the image only surfaces what they can actually run.
112
124
  */
113
125
  export function generateHelpHtml(
114
126
  helpMap: Map<string, PluginHelp>,
@@ -118,8 +130,9 @@ export function generateHelpHtml(
118
130
  botNickname: string = "Mioku Bot",
119
131
  botAvatarUrl?: string,
120
132
  targetPluginName?: string,
133
+ viewerRole: CommandRole = "master",
121
134
  ): string {
122
- const entries = getRenderableEntries(helpMap);
135
+ const entries = getRenderableEntries(helpMap, viewerRole);
123
136
  const selectedEntry = targetPluginName
124
137
  ? entries.find((entry) => entry.pluginName === targetPluginName)
125
138
  : undefined;
package/help/image.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import * as fs from "node:fs/promises";
19
- import type { HelpService, ScreenshotService } from "mioku";
19
+ import type { CommandRole, HelpService, ScreenshotService } from "mioku";
20
20
  import { checkNightMode } from "../utils";
21
21
  import { generateHelpHtml } from "./html-generator";
22
22
 
@@ -52,6 +52,10 @@ export function normalizeImageSource(file: string): string {
52
52
  * Build the help image and write it to disk via the screenshot service.
53
53
  * Returns the resulting file path, or `null` if any required service is
54
54
  * missing.
55
+ *
56
+ * `viewerRole` controls which commands are rendered — the image only
57
+ * shows commands the requester is allowed to invoke, so members don't
58
+ * see admin/master-only commands cluttering the panel.
55
59
  */
56
60
  export async function generateHelpImage(options: {
57
61
  helpService?: HelpService;
@@ -61,6 +65,7 @@ export async function generateHelpImage(options: {
61
65
  botNickname?: string;
62
66
  botAvatarUrl?: string;
63
67
  targetPluginName?: string;
68
+ viewerRole?: CommandRole;
64
69
  }): Promise<string | null> {
65
70
  const {
66
71
  helpService,
@@ -70,6 +75,7 @@ export async function generateHelpImage(options: {
70
75
  botNickname,
71
76
  botAvatarUrl,
72
77
  targetPluginName,
78
+ viewerRole,
73
79
  } = options;
74
80
  if (!helpService || !screenshotService) {
75
81
  return null;
@@ -87,6 +93,7 @@ export async function generateHelpImage(options: {
87
93
  botNickname,
88
94
  botAvatarUrl,
89
95
  hasTarget ? targetPluginName : undefined,
96
+ viewerRole,
90
97
  );
91
98
 
92
99
  return screenshotService.screenshot(htmlContent, {
package/help/index.ts CHANGED
@@ -20,5 +20,6 @@ export {
20
20
  } from "./intent";
21
21
  export { buildHelpInfoText } from "./info";
22
22
  export { generateHelpHtml } from "./html-generator";
23
+ export { canInvokeCommand, resolveViewerRole } from "./role";
23
24
  export type { HelpImageIntent, HelpRenderableEntry } from "./types";
24
25
  export { ROLE_CONFIG, STOPWORDS } from "./role-config";
package/help/intent.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * plugin names, titles, command aliases, and Chinese/English substrings.
8
8
  */
9
9
 
10
- import type { PluginHelp } from "mioku";
10
+ import type { CommandRole, PluginHelp } from "mioku";
11
+ import { canInvokeCommand } from "./role";
11
12
  import { STOPWORDS } from "./role-config";
12
13
  import type {
13
14
  HelpImageIntent,
@@ -89,15 +90,27 @@ function extractCommandAlias(command: string): string | null {
89
90
  * rendered entries are the source of truth for both keyword scoring
90
91
  * (in this file) and the HTML renderer's overview list. Exported so
91
92
  * `html-generator.ts` can reuse it instead of duplicating the logic.
93
+ *
94
+ * `viewerRole` filters out commands above the requester's permission
95
+ * level so the overview/detail views don't show commands they can't
96
+ * invoke. Defaults to `"master"` so callers that don't yet know the
97
+ * viewer (e.g. AI skill listings) keep the full registry.
92
98
  */
93
99
  export function getRenderableEntries(
94
100
  helpMap: Map<string, PluginHelp>,
101
+ viewerRole: CommandRole = "master",
95
102
  ): HelpRenderableEntry[] {
96
103
  return Array.from(helpMap.entries())
97
104
  .map(([pluginName, help]) => {
98
105
  const title = String(help.title || pluginName).trim() || pluginName;
99
106
  const description = String(help.description || "").trim();
100
- const commands = Array.isArray(help.commands) ? help.commands : [];
107
+ const allCommands = Array.isArray(help.commands) ? help.commands : [];
108
+ const commands = allCommands.filter((command) =>
109
+ canInvokeCommand(
110
+ viewerRole,
111
+ command.role as CommandRole | undefined,
112
+ ),
113
+ );
101
114
  const normalizedPluginName = normalizeForMatch(pluginName);
102
115
  const normalizedTitle = normalizeForMatch(title);
103
116
 
@@ -113,7 +126,7 @@ export function getRenderableEntries(
113
126
  keys.add(token);
114
127
  }
115
128
 
116
- const commandAliases = commands
129
+ const commandAliases = allCommands
117
130
  .map((command) => extractCommandAlias(command.cmd))
118
131
  .filter((value): value is string => Boolean(value));
119
132
  for (const alias of commandAliases) {
package/help/role.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Viewer role resolution for the help image.
3
+ *
4
+ * Decides which commands a given user is allowed to see based on the
5
+ * event's sender and the bot's owner/admin allowlists. The result is
6
+ * used to filter `getRenderableEntries` so the rendered help image
7
+ * matches what the requester can actually invoke.
8
+ */
9
+
10
+ import type { CommandRole } from "mioku";
11
+
12
+ const ROLE_RANK: Record<CommandRole, number> = {
13
+ master: 4,
14
+ admin: 3,
15
+ owner: 2,
16
+ member: 1,
17
+ };
18
+
19
+ /**
20
+ * Whether the given viewer can invoke a command gated at `commandRole`.
21
+ * A command without a `role` field is treated as member-level (visible
22
+ * to everyone).
23
+ */
24
+ export function canInvokeCommand(
25
+ viewerRole: CommandRole,
26
+ commandRole: CommandRole | undefined,
27
+ ): boolean {
28
+ const required: CommandRole = commandRole || "member";
29
+ return ROLE_RANK[viewerRole] >= ROLE_RANK[required];
30
+ }
31
+
32
+ /**
33
+ * Resolve the requesting user's effective role for help filtering.
34
+ *
35
+ * - `master` is honored everywhere (private or group) — it comes from
36
+ * mioki's `isOwner` allowlist.
37
+ * - Private chat skips the `isAdmin` check and defaults to `admin`,
38
+ * so anyone DM-ing the bot can see admin-level commands.
39
+ * - Group chat keeps the full hierarchy: admin (mioki allowlist) →
40
+ * owner (group owner via `getGroupMemberInfo`) → member.
41
+ */
42
+ export async function resolveViewerRole(
43
+ ctx: any,
44
+ event: any,
45
+ ): Promise<CommandRole> {
46
+ if (ctx?.isOwner?.(event)) {
47
+ return "master";
48
+ }
49
+
50
+ const isGroup = event?.message_type === "group";
51
+
52
+ if (isGroup) {
53
+ if (ctx?.isAdmin?.(event)) {
54
+ return "admin";
55
+ }
56
+
57
+ if (event?.group_id != null && event?.user_id != null) {
58
+ const selfId =
59
+ event?.self_id != null ? Number(event.self_id) : undefined;
60
+ const bot =
61
+ selfId != null && typeof ctx?.pickBot === "function"
62
+ ? ctx.pickBot(selfId)
63
+ : undefined;
64
+ if (bot && typeof bot.getGroupMemberInfo === "function") {
65
+ try {
66
+ const info = await bot.getGroupMemberInfo(
67
+ event.group_id,
68
+ event.user_id,
69
+ );
70
+ if (info?.role === "owner") {
71
+ return "owner";
72
+ }
73
+ } catch {
74
+ // fall through to member
75
+ }
76
+ }
77
+ }
78
+
79
+ return "member";
80
+ }
81
+
82
+ return "admin";
83
+ }
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  replyWithImage,
10
10
  resolveHelpBotProfile,
11
11
  resolveHelpImageIntent,
12
+ resolveViewerRole,
12
13
  } from "./help";
13
14
  import { getRenderVersions } from "./utils";
14
15
  import { resetHelpRuntimeState, setHelpRuntimeState } from "./runtime";
@@ -130,6 +131,7 @@ const helpPlugin = definePlugin({
130
131
 
131
132
  try {
132
133
  const { botNickname, botAvatarUrl } = resolveHelpBotProfile(ctx, event);
134
+ const viewerRole = await resolveViewerRole(ctx, event);
133
135
  const imagePath = await generateHelpImage({
134
136
  helpService,
135
137
  screenshotService,
@@ -139,6 +141,7 @@ const helpPlugin = definePlugin({
139
141
  botAvatarUrl,
140
142
  targetPluginName:
141
143
  intent.type === "detail" ? intent.pluginName : undefined,
144
+ viewerRole,
142
145
  });
143
146
 
144
147
  if (!imagePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-help",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "帮助插件,使用截图服务生成美观的帮助图片,并提供 #状态 指令",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -17,6 +17,18 @@
17
17
  "screenshot",
18
18
  "config",
19
19
  "ai"
20
+ ],
21
+ "accessHooks": [
22
+ {
23
+ "id": "帮助菜单",
24
+ "match": "/(?:[#/]\\s*(?:帮助|菜单|help)|^(?:帮助|菜单|help)(?:\\s|$))/",
25
+ "description": "匹配 #help / 帮助 / 菜单 等帮助指令"
26
+ },
27
+ {
28
+ "id": "状态",
29
+ "match": "/(?:[#/]\\s*(?:状态|zt|status)|^(?:状态|zt|status)(?:\\s|$))/",
30
+ "description": "匹配 #状态 / 状态 / zt / status 等系统状态指令"
31
+ }
20
32
  ]
21
33
  },
22
34
  "peerDependencies": {
@@ -225,7 +225,6 @@ async function collectBotStatuses(
225
225
 
226
226
  // 并行拿 OneBot API 的 status / group / friend / version
227
227
  let online = true;
228
- let onlineDurationMs = 0;
229
228
  let groupCount = 0;
230
229
  let friendCount = 0;
231
230
  let appVersion = "unknown";
@@ -248,20 +247,7 @@ async function collectBotStatuses(
248
247
  ).catch(() => null),
249
248
  ]);
250
249
  if (statusResult.status === "fulfilled" && statusResult.value) {
251
- const status = statusResult.value;
252
- // napcat-sdk 的 api() 已经把 OneBot v11 响应的 data 字段解包出来,
253
- const startTs = safeNumber(status?.stat?.start_time);
254
- if (startTs > 0) {
255
- // OneBot 约定:start_time 是 unix 秒;> 1e12 视为毫秒
256
- const startMs = startTs > 1e12 ? startTs : startTs * 1000;
257
- onlineDurationMs = Math.max(0, Date.now() - startMs);
258
- }
259
- if (typeof status?.online === "boolean") {
260
- online = status.online;
261
- } else if (typeof status?.good === "boolean") {
262
- // go-cqhttp / LLOneBot 用 good 表示总体健康
263
- online = status.good;
264
- }
250
+ online = statusResult.value.online;
265
251
  }
266
252
  if (versionResult.status === "fulfilled" && versionResult.value) {
267
253
  const v = versionResult.value;
@@ -297,7 +283,6 @@ async function collectBotStatuses(
297
283
  online,
298
284
  groupCount,
299
285
  friendCount,
300
- onlineDurationMs,
301
286
  send: counts.send,
302
287
  receive: counts.receive,
303
288
  });
@@ -71,23 +71,6 @@ function fmtBps(bps: number): string {
71
71
  return `${fmtBytes(bps)}/s`;
72
72
  }
73
73
 
74
- function fmtUptime(ms: number): string {
75
- if (!Number.isFinite(ms) || ms <= 0) {
76
- return "—";
77
- }
78
- const sec = Math.floor(ms / 1000);
79
- const days = Math.floor(sec / 86400);
80
- const hours = Math.floor((sec % 86400) / 3600);
81
- const minutes = Math.floor((sec % 3600) / 60);
82
- if (days > 0) {
83
- return `${days}天${String(hours).padStart(2, "0")}时${String(minutes).padStart(2, "0")}分`;
84
- }
85
- if (hours > 0) {
86
- return `${hours}时${String(minutes).padStart(2, "0")}分`;
87
- }
88
- return `${minutes}分`;
89
- }
90
-
91
74
  function progressBar(percent: number, color: string): string {
92
75
  const clamped = Math.max(0, Math.min(100, percent));
93
76
  return `<div class="status-bar"><div class="status-bar__fill" style="width:${clamped}%;background:${color};"></div></div>`;
@@ -162,7 +145,6 @@ function renderHero(
162
145
  { text: statusText, kind: bot.online ? "ok" : "danger" },
163
146
  { text: `好友 ${fmtNumber(bot.friendCount)}`, kind: "data" },
164
147
  { text: `群聊 ${fmtNumber(bot.groupCount)}`, kind: "data" },
165
- { text: `运行时长 ${fmtUptime(bot.onlineDurationMs)}`, kind: "data" },
166
148
  { text: `收 ${fmtNumber(bot.receive)}`, kind: "data" },
167
149
  { text: `发 ${fmtNumber(bot.send)}`, kind: "data" },
168
150
  { text: frameworkText, kind: "data" },
package/status/types.ts CHANGED
@@ -19,8 +19,6 @@ export interface BotAccountStatus {
19
19
  online: boolean;
20
20
  groupCount: number;
21
21
  friendCount: number;
22
- /** Computed from `bot.api("get_status").stat.start_time`. 0 if unavailable. */
23
- onlineDurationMs: number;
24
22
  send: number;
25
23
  receive: number;
26
24
  }
@@ -37,18 +35,13 @@ export interface OneBotVersionInfoData {
37
35
 
38
36
  /**
39
37
  * OneBot v11 `get_status` payload (already unwrapped by napcat-sdk).
40
- * Different implementations expose different subsets:
41
- * - NapCat / go-cqhttp: `online`, `good`, `stat.start_time`
42
- * - LLOneBot: same shape
38
+ * Per napcat 官方文档: `{ online, good, stat }` —— `stat` 是空对象,
39
+ * 不包含 `start_time` 或任何统计字段。本接口只声明文档承诺的字段。
43
40
  */
44
41
  export interface OneBotStatusData {
45
- online?: boolean;
46
- good?: boolean;
47
- stat?: {
48
- start_time?: number;
49
- [key: string]: unknown;
50
- };
51
- [key: string]: unknown;
42
+ online: boolean;
43
+ good: boolean;
44
+ stat: Record<string, never>;
52
45
  }
53
46
 
54
47
  /** Subset of mioku's `AIService.getUsageSummary` payload that we actually render. */