mioku-plugin-help 2.0.0 → 2.1.1

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/image.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Help image orchestration.
3
+ *
4
+ * `generateHelpImage` is the high-level entry point: it takes a help
5
+ * service, builds the HTML, and asks the screenshot service to render
6
+ * the PNG. The lower-level functions cover:
7
+ *
8
+ * - Image source normalization (`normalizeImageSource`)
9
+ * - Bot profile resolution (`resolveHelpBotProfile`)
10
+ * - Two reply helpers (`replyWithImage`, `sendImageFromSkillContext`)
11
+ * that handle the local file → base64 fallback when the network
12
+ * adapter can't send a raw path.
13
+ *
14
+ * Version detection lives in `../utils#getRenderVersions` so the help
15
+ * panel and the status panel share the same logic.
16
+ */
17
+
18
+ import * as fs from "node:fs/promises";
19
+ import type { HelpService, ScreenshotService } from "mioku";
20
+ import { checkNightMode } from "../utils";
21
+ import { generateHelpHtml } from "./html-generator";
22
+
23
+ /** POSIX or Windows absolute path → `file://...`; anything else passes through. */
24
+ function isLocalFilePath(value: string): boolean {
25
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
26
+ }
27
+
28
+ /** Prefix remote / data-URI / absolute paths so the bot framework can route them. */
29
+ export function normalizeImageSource(file: string): string {
30
+ const value = String(file || "").trim();
31
+ if (!value) {
32
+ return value;
33
+ }
34
+
35
+ if (
36
+ value.startsWith("file://") ||
37
+ value.startsWith("base64://") ||
38
+ value.startsWith("http://") ||
39
+ value.startsWith("https://")
40
+ ) {
41
+ return value;
42
+ }
43
+
44
+ if (isLocalFilePath(value)) {
45
+ return `file://${value}`;
46
+ }
47
+
48
+ return value;
49
+ }
50
+
51
+ /**
52
+ * Build the help image and write it to disk via the screenshot service.
53
+ * Returns the resulting file path, or `null` if any required service is
54
+ * missing.
55
+ */
56
+ export async function generateHelpImage(options: {
57
+ helpService?: HelpService;
58
+ screenshotService?: ScreenshotService;
59
+ miokiVersion?: string;
60
+ miokuVersion?: string;
61
+ botNickname?: string;
62
+ botAvatarUrl?: string;
63
+ targetPluginName?: string;
64
+ }): Promise<string | null> {
65
+ const {
66
+ helpService,
67
+ screenshotService,
68
+ miokiVersion,
69
+ miokuVersion,
70
+ botNickname,
71
+ botAvatarUrl,
72
+ targetPluginName,
73
+ } = options;
74
+ if (!helpService || !screenshotService) {
75
+ return null;
76
+ }
77
+
78
+ const allHelp = helpService.getAllHelp();
79
+ const hasTarget =
80
+ Boolean(targetPluginName) && allHelp.has(String(targetPluginName));
81
+
82
+ const htmlContent = generateHelpHtml(
83
+ allHelp,
84
+ checkNightMode(),
85
+ miokiVersion,
86
+ miokuVersion,
87
+ botNickname,
88
+ botAvatarUrl,
89
+ hasTarget ? targetPluginName : undefined,
90
+ );
91
+
92
+ return screenshotService.screenshot(htmlContent, {
93
+ width: 760,
94
+ height: 120,
95
+ fullPage: true,
96
+ type: "png",
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Pick a bot's nickname + avatar URL. Used so the help card can greet
102
+ * the user with their bot's identity rather than a generic string.
103
+ */
104
+ export function resolveHelpBotProfile(
105
+ ctx: any,
106
+ event?: any,
107
+ ): { botNickname: string; botAvatarUrl?: string } {
108
+ const fallbackNickname = "Mioku Bot";
109
+ const selfId = event?.self_id;
110
+ const bot =
111
+ (selfId && typeof ctx?.pickBot === "function"
112
+ ? ctx.pickBot(selfId)
113
+ : null) ||
114
+ (ctx?.bots instanceof Map ? Array.from(ctx.bots.values())[0] : null);
115
+ const botId = selfId || bot?.uin || bot?.user_id || bot?.self_id;
116
+ const botNickname = bot?.nickname || bot?.name || fallbackNickname;
117
+ const botAvatarUrl = botId
118
+ ? `https://q1.qlogo.cn/g?b=qq&nk=${botId}&s=640`
119
+ : undefined;
120
+
121
+ return { botNickname, botAvatarUrl };
122
+ }
123
+
124
+ /**
125
+ * Reply to a chat event with a local image file. Falls back to base64
126
+ * encoding if the network adapter can't send a raw file path.
127
+ */
128
+ export async function replyWithImage(
129
+ event: any,
130
+ segment: { image: (file: string) => any } | undefined,
131
+ imagePath: string,
132
+ ): Promise<void> {
133
+ if (!event?.reply) {
134
+ throw new Error("当前上下文不支持发送图片回复");
135
+ }
136
+
137
+ try {
138
+ if (segment?.image) {
139
+ await event.reply(segment.image(imagePath));
140
+ } else {
141
+ await event.reply([{ type: "image", file: imagePath }]);
142
+ }
143
+ } catch {
144
+ const imageBuffer = await fs.readFile(imagePath);
145
+ const base64Image = `base64://${imageBuffer.toString("base64")}`;
146
+
147
+ if (segment?.image) {
148
+ await event.reply(segment.image(base64Image));
149
+ } else {
150
+ await event.reply([{ type: "image", file: base64Image }]);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Send an image from inside an AI skill handler. Picks the right bot via
157
+ * `ctx.pickBot(selfId)`, supports an optional quote-reply, and falls
158
+ * back to base64 when the adapter refuses a raw path.
159
+ */
160
+ export async function sendImageFromSkillContext(options: {
161
+ ctx: any;
162
+ event: any;
163
+ imagePath: string;
164
+ quoteReply?: boolean;
165
+ }): Promise<void> {
166
+ const { ctx, event, imagePath, quoteReply = false } = options;
167
+ const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
168
+ const bot =
169
+ selfId != null && typeof ctx?.pickBot === "function"
170
+ ? ctx.pickBot(selfId)
171
+ : undefined;
172
+
173
+ if (!bot) {
174
+ throw new Error("当前上下文不支持发送图片");
175
+ }
176
+
177
+ const buildImageSegment = (file: string) => {
178
+ const normalizedFile = normalizeImageSource(file);
179
+ if (ctx?.segment?.image) {
180
+ return ctx.segment.image(normalizedFile);
181
+ }
182
+ return { type: "image", file: normalizedFile };
183
+ };
184
+
185
+ const sendPayload = async (file: string) => {
186
+ const payload: any[] = [];
187
+ if (quoteReply && event?.message_id != null) {
188
+ payload.push({ type: "reply", id: String(event.message_id) });
189
+ }
190
+ payload.push(buildImageSegment(file));
191
+
192
+ if (event?.message_type === "group" && event?.group_id != null) {
193
+ await bot.sendGroupMsg(event.group_id, payload);
194
+ return;
195
+ }
196
+
197
+ if (event?.user_id != null) {
198
+ await bot.sendPrivateMsg(event.user_id, payload);
199
+ return;
200
+ }
201
+
202
+ throw new Error("当前上下文不支持发送图片");
203
+ };
204
+
205
+ try {
206
+ await sendPayload(imagePath);
207
+ } catch (error) {
208
+ if (!isLocalFilePath(imagePath)) {
209
+ throw error;
210
+ }
211
+
212
+ const imageBuffer = await fs.readFile(imagePath);
213
+ const base64Image = `base64://${imageBuffer.toString("base64")}`;
214
+ await sendPayload(base64Image);
215
+ }
216
+ }
package/help/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Public API for the help feature.
3
+ *
4
+ * Everything the plugin entry point and the AI skills need from the
5
+ * help subsystem re-exports from here. Internal modules stay private
6
+ * to their concerns.
7
+ */
8
+
9
+ export {
10
+ generateHelpImage,
11
+ resolveHelpBotProfile,
12
+ replyWithImage,
13
+ sendImageFromSkillContext,
14
+ normalizeImageSource,
15
+ } from "./image";
16
+ export {
17
+ resolveHelpImageIntent,
18
+ findPluginHelpByKeyword,
19
+ getRenderableEntries,
20
+ } from "./intent";
21
+ export { buildHelpInfoText } from "./info";
22
+ export { generateHelpHtml } from "./html-generator";
23
+ export type { HelpImageIntent, HelpRenderableEntry } from "./types";
24
+ export { ROLE_CONFIG, STOPWORDS } from "./role-config";
package/help/info.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Build a plain-text version of the help registry.
3
+ *
4
+ * Used by AI skills: when the LLM is asked about a feature, the
5
+ * `get_help_info` tool returns this text instead of an image, so the
6
+ * model can read what's available without vision.
7
+ */
8
+
9
+ import type { PluginHelp } from "mioku";
10
+ import { ROLE_CONFIG } from "./role-config";
11
+
12
+ /**
13
+ * Render the entire help registry as a single string the AI can read.
14
+ * Format:
15
+ *
16
+ * === Mioku Bot 帮助信息 ===
17
+ *
18
+ * 【插件标题】描述
19
+ * #cmd [角色] - 描述
20
+ * ...
21
+ */
22
+ export function buildHelpInfoText(
23
+ helpMap: Map<string, PluginHelp>,
24
+ ): string {
25
+ const info: string[] = ["=== Mioku Bot 帮助信息 ===\n"];
26
+
27
+ for (const [pluginName, help] of helpMap) {
28
+ info.push(`【${help.title || pluginName}】${help.description || ""}`);
29
+ if (help.commands?.length) {
30
+ for (const cmd of help.commands) {
31
+ const roleLabel = cmd.role
32
+ ? ` [${ROLE_CONFIG[cmd.role]?.label || cmd.role}]`
33
+ : "";
34
+ info.push(` ${cmd.cmd}${roleLabel} - ${cmd.desc}`);
35
+ }
36
+ }
37
+ info.push("");
38
+ }
39
+
40
+ return info.join("\n");
41
+ }
package/help/intent.ts ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Help intent resolution and keyword matching.
3
+ *
4
+ * `resolveHelpImageIntent` parses a chat message into one of four actions:
5
+ * "show overview", "show detail for plugin X", "we don't recognize this
6
+ * plugin", or "not a help command at all". Matching is fuzzy: we accept
7
+ * plugin names, titles, command aliases, and Chinese/English substrings.
8
+ */
9
+
10
+ import type { PluginHelp } from "mioku";
11
+ import { STOPWORDS } from "./role-config";
12
+ import type {
13
+ HelpImageIntent,
14
+ HelpKeywordCandidate,
15
+ HelpRenderableEntry,
16
+ } from "./types";
17
+
18
+ /**
19
+ * Strip the leading command marker (`#`, `/`) and trailing punctuation,
20
+ * then collapse to a comparable form. Returns "" for empty input.
21
+ */
22
+ function sanitizeKeyword(value: string): string {
23
+ return String(value || "")
24
+ .trim()
25
+ .replace(/^[#/\s]+/, "")
26
+ .replace(/[。.!!??,,::;;]+$/g, "");
27
+ }
28
+
29
+ /** Lowercase + strip everything that isn't a-z, 0-9, or CJK. */
30
+ function normalizeForMatch(value: string): string {
31
+ const parts = String(value || "")
32
+ .toLowerCase()
33
+ .match(/[a-z0-9\u4e00-\u9fa5]+/gi);
34
+
35
+ return parts ? parts.join("") : "";
36
+ }
37
+
38
+ /** Pull every token of length ≥ 2 that isn't a stop-word. */
39
+ function extractMatchTokens(text: string): string[] {
40
+ const rawTokens = String(text || "")
41
+ .toLowerCase()
42
+ .match(/[a-z0-9\u4e00-\u9fa5]+/gi);
43
+
44
+ if (!rawTokens) {
45
+ return [];
46
+ }
47
+
48
+ const tokens = rawTokens
49
+ .map((token) => normalizeForMatch(token))
50
+ .filter((token) => token.length >= 2)
51
+ .filter((token) => !STOPWORDS.has(token));
52
+
53
+ return Array.from(new Set(tokens));
54
+ }
55
+
56
+ /**
57
+ * The first whitespace-separated token of a command, with the `#` / `/`
58
+ * prefix removed. Used as an additional alias when matching plugin
59
+ * commands. Returns null if the first token is empty, contains `<>` (a
60
+ * placeholder), or has no alphanumeric characters.
61
+ */
62
+ function extractCommandAlias(command: string): string | null {
63
+ const value = String(command || "")
64
+ .trim()
65
+ .replace(/^[#/]+/, "");
66
+ if (!value) {
67
+ return null;
68
+ }
69
+
70
+ const firstToken = value.split(/\s+/)[0];
71
+ if (!firstToken || /[<>]/.test(firstToken)) {
72
+ return null;
73
+ }
74
+
75
+ if (!/[a-z0-9]/i.test(firstToken)) {
76
+ return null;
77
+ }
78
+
79
+ const normalized = normalizeForMatch(firstToken);
80
+ if (!normalized || STOPWORDS.has(normalized)) {
81
+ return null;
82
+ }
83
+
84
+ return normalized;
85
+ }
86
+
87
+ /**
88
+ * Build a sorted list of `HelpRenderableEntry` from a help map. The
89
+ * rendered entries are the source of truth for both keyword scoring
90
+ * (in this file) and the HTML renderer's overview list. Exported so
91
+ * `html-generator.ts` can reuse it instead of duplicating the logic.
92
+ */
93
+ export function getRenderableEntries(
94
+ helpMap: Map<string, PluginHelp>,
95
+ ): HelpRenderableEntry[] {
96
+ return Array.from(helpMap.entries())
97
+ .map(([pluginName, help]) => {
98
+ const title = String(help.title || pluginName).trim() || pluginName;
99
+ const description = String(help.description || "").trim();
100
+ const commands = Array.isArray(help.commands) ? help.commands : [];
101
+ const normalizedPluginName = normalizeForMatch(pluginName);
102
+ const normalizedTitle = normalizeForMatch(title);
103
+
104
+ const keys = new Set<string>();
105
+ if (normalizedPluginName) {
106
+ keys.add(normalizedPluginName);
107
+ }
108
+ if (normalizedTitle) {
109
+ keys.add(normalizedTitle);
110
+ }
111
+
112
+ for (const token of extractMatchTokens(title)) {
113
+ keys.add(token);
114
+ }
115
+
116
+ const commandAliases = commands
117
+ .map((command) => extractCommandAlias(command.cmd))
118
+ .filter((value): value is string => Boolean(value));
119
+ for (const alias of commandAliases) {
120
+ keys.add(alias);
121
+ }
122
+
123
+ return {
124
+ pluginName,
125
+ title,
126
+ description,
127
+ commands,
128
+ normalizedPluginName,
129
+ normalizedTitle,
130
+ matchKeys: keys,
131
+ };
132
+ })
133
+ .sort((a, b) => a.pluginName.localeCompare(b.pluginName, "zh-Hans-CN"));
134
+ }
135
+
136
+ /**
137
+ * Score a query against an entry. Higher = better match. Used to break
138
+ * ties between fuzzy candidates.
139
+ *
140
+ * - 58: prefix overlap (e.g. "wea" matches "weather")
141
+ * - 46: title containment
142
+ * - 34: substring overlap
143
+ * - 0: no match
144
+ */
145
+ function scoreKeywordMatch(
146
+ query: string,
147
+ entry: HelpRenderableEntry,
148
+ ): number {
149
+ let score = 0;
150
+
151
+ if (
152
+ entry.normalizedTitle.includes(query) ||
153
+ query.includes(entry.normalizedTitle)
154
+ ) {
155
+ score = Math.max(
156
+ score,
157
+ 46 - Math.abs(entry.normalizedTitle.length - query.length),
158
+ );
159
+ }
160
+
161
+ for (const key of entry.matchKeys) {
162
+ if (key.startsWith(query) || query.startsWith(key)) {
163
+ score = Math.max(score, 58 - Math.abs(key.length - query.length));
164
+ continue;
165
+ }
166
+
167
+ if (key.includes(query) || query.includes(key)) {
168
+ score = Math.max(score, 34 - Math.abs(key.length - query.length));
169
+ }
170
+ }
171
+
172
+ return Math.max(0, score);
173
+ }
174
+
175
+ /**
176
+ * Extract every possible plugin keyword from a chat message. Each
177
+ * candidate carries a `strictUnknown` flag: strict patterns (e.g. the
178
+ * user clearly asked about a specific plugin) should produce a
179
+ * "no plugin found" reply, loose patterns should silently fall through.
180
+ */
181
+ function extractHelpKeywordCandidates(
182
+ text: string,
183
+ ): HelpKeywordCandidate[] {
184
+ const source = text.trim();
185
+ const candidates: HelpKeywordCandidate[] = [];
186
+
187
+ const addCandidate = (value: string, strictUnknown: boolean) => {
188
+ const keyword = sanitizeKeyword(value);
189
+ if (!keyword) {
190
+ return;
191
+ }
192
+ if (
193
+ !candidates.some(
194
+ (candidate) =>
195
+ candidate.keyword === keyword &&
196
+ candidate.strictUnknown === strictUnknown,
197
+ )
198
+ ) {
199
+ candidates.push({ keyword, strictUnknown });
200
+ }
201
+ };
202
+
203
+ const helpPrefixSeparated = source.match(
204
+ /^[#/]?\s*(?:help|帮助)(?:\s+|[::])\s*(.+)$/i,
205
+ );
206
+ if (helpPrefixSeparated?.[1]) {
207
+ addCandidate(helpPrefixSeparated[1], true);
208
+ }
209
+
210
+ const helpPrefixMerged = source.match(
211
+ /^[#/]?\s*(?:help|帮助)([a-z0-9\u4e00-\u9fa5]+)$/i,
212
+ );
213
+ if (helpPrefixMerged?.[1]) {
214
+ addCandidate(helpPrefixMerged[1], false);
215
+ }
216
+
217
+ if (/^[#/]/.test(source)) {
218
+ return candidates;
219
+ }
220
+
221
+ const helpSuffixSeparated = source.match(/^(.+?)\s+(?:help|帮助)\s*$/i);
222
+ if (helpSuffixSeparated?.[1]) {
223
+ addCandidate(helpSuffixSeparated[1], true);
224
+ }
225
+
226
+ const helpSuffixMerged = source.match(
227
+ /^([a-z0-9\u4e00-\u9fa5]+)(?:help|帮助)\s*$/i,
228
+ );
229
+ if (helpSuffixMerged?.[1]) {
230
+ addCandidate(helpSuffixMerged[1], false);
231
+ }
232
+
233
+ const menuPrefix = source.match(
234
+ /^菜单\s*[::]?\s*([a-z0-9\u4e00-\u9fa5]+)$/i,
235
+ );
236
+ if (menuPrefix?.[1]) {
237
+ addCandidate(menuPrefix[1], false);
238
+ }
239
+
240
+ const menuSuffix = source.match(/^([a-z0-9\u4e00-\u9fa5]+)\s*菜单\s*$/i);
241
+ if (menuSuffix?.[1]) {
242
+ addCandidate(menuSuffix[1], false);
243
+ }
244
+
245
+ return candidates;
246
+ }
247
+
248
+ /**
249
+ * Resolve a keyword to a plugin help entry. Tries exact, then
250
+ * start-substring, then fuzzy. Returns null if the query is too short
251
+ * (< 2 chars), matches a stop-word, or has ambiguous results.
252
+ */
253
+ export function findPluginHelpByKeyword(
254
+ helpMap: Map<string, PluginHelp>,
255
+ keyword: string,
256
+ ): { pluginName: string; help: PluginHelp } | null {
257
+ const normalizedQuery = normalizeForMatch(sanitizeKeyword(keyword));
258
+ if (!normalizedQuery || STOPWORDS.has(normalizedQuery)) {
259
+ return null;
260
+ }
261
+
262
+ const entries = getRenderableEntries(helpMap);
263
+ if (entries.length === 0) {
264
+ return null;
265
+ }
266
+
267
+ const directPlugin = entries.find(
268
+ (entry) => entry.normalizedPluginName === normalizedQuery,
269
+ );
270
+ if (directPlugin) {
271
+ return {
272
+ pluginName: directPlugin.pluginName,
273
+ help: helpMap.get(directPlugin.pluginName)!,
274
+ };
275
+ }
276
+
277
+ const exactMatches = entries.filter((entry) =>
278
+ entry.matchKeys.has(normalizedQuery),
279
+ );
280
+ if (exactMatches.length === 1) {
281
+ return {
282
+ pluginName: exactMatches[0].pluginName,
283
+ help: helpMap.get(exactMatches[0].pluginName)!,
284
+ };
285
+ }
286
+
287
+ if (normalizedQuery.length < 2) {
288
+ return null;
289
+ }
290
+
291
+ const fuzzyMatches = entries
292
+ .map((entry) => ({
293
+ entry,
294
+ score: scoreKeywordMatch(normalizedQuery, entry),
295
+ }))
296
+ .filter((item) => item.score > 0)
297
+ .sort((a, b) => b.score - a.score);
298
+
299
+ if (fuzzyMatches.length === 0) {
300
+ return null;
301
+ }
302
+
303
+ if (
304
+ fuzzyMatches.length > 1 &&
305
+ fuzzyMatches[0].score === fuzzyMatches[1].score
306
+ ) {
307
+ return null;
308
+ }
309
+
310
+ const resolved = fuzzyMatches[0].entry;
311
+ return {
312
+ pluginName: resolved.pluginName,
313
+ help: helpMap.get(resolved.pluginName)!,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Parse a chat message into a help image action.
319
+ *
320
+ * Returns:
321
+ * - `{ type: "overview" }` for plain `#help` / `帮助` / `菜单`
322
+ * - `{ type: "detail", ... }` when a plugin name is recognized
323
+ * - `{ type: "unknown", keyword }` when the user clearly named a plugin
324
+ * but we couldn't find it (only for "strict" patterns like "X 帮助")
325
+ * - `{ type: "none" }` for anything else
326
+ */
327
+ export function resolveHelpImageIntent(
328
+ text: string,
329
+ helpMap: Map<string, PluginHelp>,
330
+ ): HelpImageIntent {
331
+ const source = String(text || "").trim();
332
+ if (!source) {
333
+ return { type: "none" };
334
+ }
335
+
336
+ if (
337
+ /^[#/]/.test(source) &&
338
+ !/^[#/]\s*(?:help|帮助|菜单)/i.test(source)
339
+ ) {
340
+ return { type: "none" };
341
+ }
342
+
343
+ if (/^[#/]?\s*(?:help|帮助|菜单)\s*$/i.test(source)) {
344
+ return { type: "overview" };
345
+ }
346
+
347
+ const candidates = extractHelpKeywordCandidates(source);
348
+ if (candidates.length === 0) {
349
+ return { type: "none" };
350
+ }
351
+
352
+ for (const candidate of candidates) {
353
+ const resolved = findPluginHelpByKeyword(helpMap, candidate.keyword);
354
+ if (resolved) {
355
+ return {
356
+ type: "detail",
357
+ pluginName: resolved.pluginName,
358
+ pluginHelp: resolved.help,
359
+ };
360
+ }
361
+ }
362
+
363
+ const strictCandidate = candidates.find(
364
+ (candidate) => candidate.strictUnknown,
365
+ );
366
+ if (!strictCandidate) {
367
+ return { type: "none" };
368
+ }
369
+
370
+ const fallback = sanitizeKeyword(strictCandidate.keyword);
371
+ if (!fallback) {
372
+ return { type: "none" };
373
+ }
374
+
375
+ return {
376
+ type: "unknown",
377
+ keyword: fallback,
378
+ };
379
+ }