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.
- package/help/html-generator.ts +16 -3
- package/help/image.ts +8 -1
- package/help/index.ts +1 -0
- package/help/intent.ts +16 -3
- package/help/role.ts +83 -0
- package/index.ts +3 -0
- package/package.json +13 -1
- package/status/data-collector.ts +1 -16
- package/status/html-generator.ts +0 -18
- package/status/types.ts +5 -12
package/help/html-generator.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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.
|
|
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": {
|
package/status/data-collector.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/status/html-generator.ts
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* - LLOneBot: same shape
|
|
38
|
+
* Per napcat 官方文档: `{ online, good, stat }` —— `stat` 是空对象,
|
|
39
|
+
* 不包含 `start_time` 或任何统计字段。本接口只声明文档承诺的字段。
|
|
43
40
|
*/
|
|
44
41
|
export interface OneBotStatusData {
|
|
45
|
-
online
|
|
46
|
-
good
|
|
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. */
|