@zhin.js/command 1.0.11 → 1.0.13

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.
@@ -1,5 +1,5 @@
1
1
  import type { CapabilitySlot, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
- import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment } from './definition.js';
2
+ import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment, type CommandPromptFactory } from './definition.js';
3
3
  export interface CommandParameterDescriptor extends CommandParameterDefinition {
4
4
  readonly required: boolean;
5
5
  }
@@ -20,15 +20,18 @@ export interface CommandDispatchResult {
20
20
  readonly value?: unknown;
21
21
  }
22
22
  export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
23
+ export interface CommandMenuConfig {
24
+ readonly keyword: string;
25
+ }
23
26
  export declare class CommandIndex {
24
27
  #private;
25
28
  private readonly snapshot;
26
29
  readonly $projection: "zhin.command-index/1";
27
- constructor(slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[], snapshot: RuntimeSnapshot);
30
+ constructor(slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[], snapshot: RuntimeSnapshot, menu?: CommandMenuConfig);
28
31
  list(): readonly CommandDescriptor[];
29
32
  has(name: string): boolean;
30
33
  execute(name: string, args?: readonly string[]): Promise<unknown>;
31
- dispatch(input: CommandMatchInput, source?: unknown): Promise<CommandDispatchResult>;
34
+ dispatch(input: CommandMatchInput, source?: unknown, promptFactory?: CommandPromptFactory, commandPrefix?: string): Promise<CommandDispatchResult>;
32
35
  }
33
36
  export declare function isCommandIndex(value: unknown): value is CommandIndex;
34
37
  export declare class CommandParameterValueError extends TypeError {
@@ -19,8 +19,10 @@ export class CommandIndex {
19
19
  #commands;
20
20
  #routes;
21
21
  #shortcuts;
22
- constructor(slots, snapshot) {
22
+ #menu;
23
+ constructor(slots, snapshot, menu) {
23
24
  this.snapshot = snapshot;
25
+ this.#menu = menu;
24
26
  const commands = [];
25
27
  const routes = [];
26
28
  const occupancy = new Map();
@@ -117,13 +119,25 @@ export class CommandIndex {
117
119
  // Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
118
120
  return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, undefined)));
119
121
  }
120
- async dispatch(input, source = undefined) {
122
+ async dispatch(input, source = undefined, promptFactory, commandPrefix = '') {
123
+ if (this.#menu) {
124
+ const menuValue = this.#dispatchMenu(input, commandPrefix);
125
+ if (menuValue !== undefined) {
126
+ return Object.freeze({
127
+ matched: true,
128
+ command: this.#menu.keyword,
129
+ owner: this.snapshot.root,
130
+ value: menuValue,
131
+ });
132
+ }
133
+ }
134
+ const prompt = promptFactory?.(source);
121
135
  const shortcut = this.#matchShortcut(input);
122
136
  if (shortcut) {
123
137
  if (!(await this.#permitAllows(shortcut.record, source))) {
124
138
  return Object.freeze({ matched: false });
125
139
  }
126
- const value = await shortcut.record.slot.definition.execute(createCommandContext(this.snapshot, shortcut.record.slot.owner, Object.freeze([]), resolveDynamicParams(shortcut.params, source), source, Object.freeze([])));
140
+ const value = await shortcut.record.slot.definition.execute(createCommandContext(this.snapshot, shortcut.record.slot.owner, Object.freeze([]), resolveDynamicParams(shortcut.params, source), source, Object.freeze([]), prompt));
127
141
  return Object.freeze({
128
142
  matched: true,
129
143
  command: shortcut.record.name,
@@ -138,7 +152,7 @@ export class CommandIndex {
138
152
  return Object.freeze({ matched: false });
139
153
  }
140
154
  const args = textArgs(match.remaining);
141
- const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, source), source, match.remaining));
155
+ const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, source), source, match.remaining, prompt));
142
156
  return Object.freeze({
143
157
  matched: true,
144
158
  command: match.command.name,
@@ -232,6 +246,65 @@ export class CommandIndex {
232
246
  throw new CommandParameterValueError(parameter.name, parameter.type, value);
233
247
  }
234
248
  }
249
+ // ==========================================================================
250
+ // 内置菜单命令
251
+ // ==========================================================================
252
+ #dispatchMenu(input, commandPrefix = '') {
253
+ const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
254
+ if (text === undefined)
255
+ return undefined;
256
+ const keyword = this.#menu.keyword;
257
+ if (text === keyword)
258
+ return this.#buildMenu(undefined, commandPrefix);
259
+ if (text.startsWith(keyword + ' ')) {
260
+ const key = text.slice(keyword.length + 1).trim();
261
+ if (key)
262
+ return this.#buildMenu(key, commandPrefix);
263
+ return this.#buildMenu(undefined, commandPrefix);
264
+ }
265
+ return undefined;
266
+ }
267
+ #buildMenu(pluginKey, commandPrefix = '') {
268
+ const root = this.snapshot.root;
269
+ const targetId = pluginKey
270
+ ? `${root}/${pluginKey.split('.').join('/')}`
271
+ : root;
272
+ const node = this.snapshot.tree.get(targetId);
273
+ if (!node)
274
+ return `未找到插件: ${pluginKey}`;
275
+ const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
276
+ const children = node.children
277
+ .map((childId) => this.snapshot.tree.get(childId))
278
+ .filter((child) => !!child);
279
+ const keyword = this.#menu.keyword;
280
+ const displayKey = pluginKey ?? node.instanceKey;
281
+ const lines = [`=== ${displayKey} 指令菜单 ===`];
282
+ if (directCommands.length > 0) {
283
+ lines.push('');
284
+ for (const cmd of directCommands) {
285
+ const desc = cmd.description ? ` - ${cmd.description}` : '';
286
+ lines.push(` ${commandPrefix}${cmd.name}${desc}`);
287
+ }
288
+ }
289
+ if (children.length > 0) {
290
+ lines.push('');
291
+ lines.push('子插件:');
292
+ for (const child of children) {
293
+ const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
294
+ const label = child.metadata?.displayName ?? child.instanceKey;
295
+ lines.push(` ${childKey} (${label})`);
296
+ }
297
+ }
298
+ if (directCommands.length === 0 && children.length === 0) {
299
+ lines.push('');
300
+ lines.push('(暂无指令和子插件)');
301
+ }
302
+ if (children.length > 0) {
303
+ lines.push('');
304
+ lines.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
305
+ }
306
+ return lines.join('\n');
307
+ }
235
308
  }
236
309
  export function isCommandIndex(value) {
237
310
  return !!value && typeof value === 'object'
@@ -143,6 +143,56 @@ export interface CommandSession {
143
143
  /** 发送者对象(id / name / role[])。 */
144
144
  readonly sender?: CommandSender;
145
145
  }
146
+ export interface CommandPromptOptions {
147
+ readonly timeout?: number;
148
+ readonly timeoutText?: string;
149
+ /** Cancel the pending claim when the owning turn aborts. */
150
+ readonly signal?: AbortSignal;
151
+ }
152
+ export interface CommandPromptListOptions extends CommandPromptOptions {
153
+ readonly type?: 'text' | 'number' | 'boolean';
154
+ readonly separator?: string;
155
+ readonly default?: readonly (string | number | boolean)[];
156
+ }
157
+ export interface CommandPromptPickOptions<V = unknown> extends CommandPromptOptions {
158
+ readonly options: readonly {
159
+ readonly label: string;
160
+ readonly value: V;
161
+ }[];
162
+ readonly multiple?: boolean;
163
+ readonly separator?: string;
164
+ readonly default?: V | readonly V[];
165
+ }
166
+ /**
167
+ * 命令内对话式交互输入。
168
+ *
169
+ * IM 派发时自动注入(`context.prompt`);Host / CLI 无消息来源时为 `undefined`。
170
+ *
171
+ * ```ts
172
+ * defineCommand({
173
+ * execute: async (context) => {
174
+ * const name = await context.prompt!.text('请输入你的名字');
175
+ * const age = await context.prompt!.number('请输入你的年龄');
176
+ * return `你好 ${name},你 ${age} 岁了`;
177
+ * },
178
+ * });
179
+ * ```
180
+ */
181
+ export interface CommandPrompt {
182
+ text(tips: string, options?: CommandPromptOptions & {
183
+ readonly default?: string;
184
+ }): Promise<string>;
185
+ number(tips: string, options?: CommandPromptOptions & {
186
+ readonly default?: number;
187
+ }): Promise<number>;
188
+ confirm(tips: string, options?: CommandPromptOptions & {
189
+ readonly condition?: string;
190
+ readonly default?: boolean;
191
+ }): Promise<boolean>;
192
+ list(tips: string, options?: CommandPromptListOptions): Promise<readonly (string | number | boolean)[]>;
193
+ pick<V = unknown>(tips: string, options: CommandPromptPickOptions<V>): Promise<V | readonly V[]>;
194
+ }
195
+ export type CommandPromptFactory = (source: unknown) => CommandPrompt | undefined;
146
196
  export interface CommandContext<TConfig = unknown, TInput extends CommandMessage = CommandMessage> extends CapabilityContext<TConfig>, CommandSession {
147
197
  readonly args: readonly string[];
148
198
  readonly params: Readonly<Record<string, CommandParameterValue>>;
@@ -153,6 +203,10 @@ export interface CommandContext<TConfig = unknown, TInput extends CommandMessage
153
203
  * Host / `CommandIndex.execute` 等无消息路径可能为 `undefined`。
154
204
  */
155
205
  readonly input?: TInput;
206
+ /**
207
+ * 对话式交互输入。IM 派发时自动注入;无消息来源时为 `undefined`。
208
+ */
209
+ readonly prompt?: CommandPrompt;
156
210
  }
157
211
  export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage> {
158
212
  readonly $feature: typeof commandBrand;
@@ -197,7 +251,7 @@ export declare function parseCommandDefinition(value: unknown): CommandDefinitio
197
251
  * 函数值接收从 `source`(通常是 IM Runtime `Message`)解析出的 {@link CommandSession}。
198
252
  */
199
253
  export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
200
- export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[]): CommandContext;
254
+ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], prompt?: CommandPrompt): CommandContext;
201
255
  /**
202
256
  * 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
203
257
  * 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
package/lib/definition.js CHANGED
@@ -112,7 +112,7 @@ export function resolveDynamicParams(params, source) {
112
112
  }
113
113
  return Object.freeze(resolved);
114
114
  }
115
- export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([])) {
115
+ export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), prompt) {
116
116
  const context = createCapabilityContext(snapshot, ownerId);
117
117
  const session = resolveCommandSession(input);
118
118
  return Object.freeze({
@@ -122,6 +122,7 @@ export function createCommandContext(snapshot, ownerId, args, params = Object.fr
122
122
  params: Object.freeze({ ...params }),
123
123
  segments: freezeSegments(segments),
124
124
  ...(input !== undefined ? { input: input } : {}),
125
+ ...(prompt !== undefined ? { prompt } : {}),
125
126
  });
126
127
  }
127
128
  /**
@@ -179,15 +180,10 @@ function resolveSender(input, metadata) {
179
180
  if (isCommandSender(structured)) {
180
181
  return freezeSender(structured);
181
182
  }
182
- const $sender = input.$sender;
183
- const id = input.sender?.id
184
- || (typeof $sender?.id === 'string' ? $sender.id : undefined)
185
- || firstString(metadata?.user_id, metadata?.userId);
183
+ const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
186
184
  if (!id)
187
185
  return undefined;
188
- const name = input.sender?.name
189
- || (typeof $sender?.name === 'string' ? $sender.name : undefined)
190
- || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
186
+ const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
191
187
  const role = resolveRoles(input, metadata);
192
188
  return Object.freeze({
193
189
  id,
@@ -207,19 +203,16 @@ function resolveRoles(input, metadata) {
207
203
  if (trimmed && !roles.includes(trimmed))
208
204
  roles.push(trimmed);
209
205
  };
210
- // IM Message 的 $sender 携带 enrich 快照(isMaster/isTrusted)和平台群角色(role)
211
- const $sender = input.$sender;
212
- if ($sender?.isMaster === true || metadata?.isMaster === true)
213
- push('master');
214
- else if ($sender?.isTrusted === true || metadata?.isTrusted === true)
215
- push('trusted');
216
- push($sender?.role);
217
206
  if (Array.isArray(metadata?.roles)) {
218
207
  for (const item of metadata.roles)
219
208
  push(item);
220
209
  }
221
210
  push(metadata?.senderRole);
222
211
  push(metadata?.role);
212
+ if (metadata?.isMaster === true)
213
+ push('master');
214
+ if (metadata?.isTrusted === true)
215
+ push('trusted');
223
216
  if (roles.length === 0)
224
217
  roles.push('user');
225
218
  return Object.freeze(roles);
package/lib/provider.js CHANGED
@@ -132,7 +132,12 @@ const commandFeature = defineFeatureProvider({
132
132
  },
133
133
  runtime: {
134
134
  project(slots, context) {
135
- return { value: new CommandIndex(slots, context.snapshot) };
135
+ const rootConfig = context.snapshot.config.get(context.snapshot.root);
136
+ const rawKeyword = rootConfig?.menuKeyword;
137
+ const menu = rawKeyword === false || rawKeyword === ''
138
+ ? undefined
139
+ : { keyword: typeof rawKeyword === 'string' ? rawKeyword : '菜单' };
140
+ return { value: new CommandIndex(slots, context.snapshot, menu) };
136
141
  },
137
142
  },
138
143
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,9 +18,9 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "segment-matcher": "^1.0.5",
21
- "@zhin.js/feature-kit": "1.0.8",
22
- "@zhin.js/permission": "1.0.1",
23
- "@zhin.js/plugin-runtime": "1.1.5"
21
+ "@zhin.js/feature-kit": "1.0.10",
22
+ "@zhin.js/permission": "1.0.2",
23
+ "@zhin.js/plugin-runtime": "1.1.6"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.1.2",
@@ -17,6 +17,7 @@ import {
17
17
  type CommandParameterValue,
18
18
  type CommandSegment,
19
19
  type CommandDynamicValue,
20
+ type CommandPromptFactory,
20
21
  resolveDynamicParams,
21
22
  } from './definition.js';
22
23
  import { permissionHostToken, type PermissionHost } from '@zhin.js/permission';
@@ -83,16 +84,23 @@ const segmentFields = {
83
84
  rps: 'result',
84
85
  };
85
86
 
87
+ export interface CommandMenuConfig {
88
+ readonly keyword: string;
89
+ }
90
+
86
91
  export class CommandIndex {
87
92
  readonly $projection = 'zhin.command-index/1' as const;
88
93
  readonly #commands: readonly CommandRecord[];
89
94
  readonly #routes: readonly CommandRoute[];
90
95
  readonly #shortcuts: ReadonlyMap<string, ShortcutEntry>;
96
+ readonly #menu?: CommandMenuConfig;
91
97
 
92
98
  constructor(
93
99
  slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
94
100
  private readonly snapshot: RuntimeSnapshot,
101
+ menu?: CommandMenuConfig,
95
102
  ) {
103
+ this.#menu = menu;
96
104
  const commands: CommandRecord[] = [];
97
105
  const routes: CommandRoute[] = [];
98
106
  const occupancy = new Map<string, string>();
@@ -213,7 +221,21 @@ export class CommandIndex {
213
221
  async dispatch(
214
222
  input: CommandMatchInput,
215
223
  source: unknown = undefined,
224
+ promptFactory?: CommandPromptFactory,
225
+ commandPrefix = '',
216
226
  ): Promise<CommandDispatchResult> {
227
+ if (this.#menu) {
228
+ const menuValue = this.#dispatchMenu(input, commandPrefix);
229
+ if (menuValue !== undefined) {
230
+ return Object.freeze({
231
+ matched: true,
232
+ command: this.#menu.keyword,
233
+ owner: this.snapshot.root,
234
+ value: menuValue,
235
+ });
236
+ }
237
+ }
238
+ const prompt = promptFactory?.(source);
217
239
  const shortcut = this.#matchShortcut(input);
218
240
  if (shortcut) {
219
241
  if (!(await this.#permitAllows(shortcut.record, source))) {
@@ -227,6 +249,7 @@ export class CommandIndex {
227
249
  resolveDynamicParams(shortcut.params, source),
228
250
  source,
229
251
  Object.freeze([]),
252
+ prompt,
230
253
  ),
231
254
  );
232
255
  return Object.freeze({
@@ -251,6 +274,7 @@ export class CommandIndex {
251
274
  resolveDynamicParams(match.params, source),
252
275
  source,
253
276
  match.remaining,
277
+ prompt,
254
278
  ),
255
279
  );
256
280
  return Object.freeze({
@@ -341,6 +365,72 @@ export class CommandIndex {
341
365
  throw new CommandParameterValueError(parameter.name, parameter.type, value);
342
366
  }
343
367
  }
368
+
369
+ // ==========================================================================
370
+ // 内置菜单命令
371
+ // ==========================================================================
372
+
373
+ #dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
374
+ const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
375
+ if (text === undefined) return undefined;
376
+ const keyword = this.#menu!.keyword;
377
+ if (text === keyword) return this.#buildMenu(undefined, commandPrefix);
378
+ if (text.startsWith(keyword + ' ')) {
379
+ const key = text.slice(keyword.length + 1).trim();
380
+ if (key) return this.#buildMenu(key, commandPrefix);
381
+ return this.#buildMenu(undefined, commandPrefix);
382
+ }
383
+ return undefined;
384
+ }
385
+
386
+ #buildMenu(pluginKey?: string, commandPrefix = ''): string {
387
+ const root = this.snapshot.root;
388
+ const targetId = pluginKey
389
+ ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
390
+ : root;
391
+
392
+ const node = this.snapshot.tree.get(targetId);
393
+ if (!node) return `未找到插件: ${pluginKey}`;
394
+
395
+ const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
396
+ const children = node.children
397
+ .map((childId) => this.snapshot.tree.get(childId))
398
+ .filter((child): child is NonNullable<typeof child> => !!child);
399
+
400
+ const keyword = this.#menu!.keyword;
401
+ const displayKey = pluginKey ?? node.instanceKey;
402
+ const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
403
+
404
+ if (directCommands.length > 0) {
405
+ lines.push('');
406
+ for (const cmd of directCommands) {
407
+ const desc = cmd.description ? ` - ${cmd.description}` : '';
408
+ lines.push(` ${commandPrefix}${cmd.name}${desc}`);
409
+ }
410
+ }
411
+
412
+ if (children.length > 0) {
413
+ lines.push('');
414
+ lines.push('子插件:');
415
+ for (const child of children) {
416
+ const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
417
+ const label = child.metadata?.displayName ?? child.instanceKey;
418
+ lines.push(` ${childKey} (${label})`);
419
+ }
420
+ }
421
+
422
+ if (directCommands.length === 0 && children.length === 0) {
423
+ lines.push('');
424
+ lines.push('(暂无指令和子插件)');
425
+ }
426
+
427
+ if (children.length > 0) {
428
+ lines.push('');
429
+ lines.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
430
+ }
431
+
432
+ return lines.join('\n');
433
+ }
344
434
  }
345
435
 
346
436
  export function isCommandIndex(value: unknown): value is CommandIndex {
package/src/definition.ts CHANGED
@@ -193,6 +193,51 @@ export interface CommandSession {
193
193
  readonly sender?: CommandSender;
194
194
  }
195
195
 
196
+ export interface CommandPromptOptions {
197
+ readonly timeout?: number;
198
+ readonly timeoutText?: string;
199
+ /** Cancel the pending claim when the owning turn aborts. */
200
+ readonly signal?: AbortSignal;
201
+ }
202
+
203
+ export interface CommandPromptListOptions extends CommandPromptOptions {
204
+ readonly type?: 'text' | 'number' | 'boolean';
205
+ readonly separator?: string;
206
+ readonly default?: readonly (string | number | boolean)[];
207
+ }
208
+
209
+ export interface CommandPromptPickOptions<V = unknown> extends CommandPromptOptions {
210
+ readonly options: readonly { readonly label: string; readonly value: V }[];
211
+ readonly multiple?: boolean;
212
+ readonly separator?: string;
213
+ readonly default?: V | readonly V[];
214
+ }
215
+
216
+ /**
217
+ * 命令内对话式交互输入。
218
+ *
219
+ * IM 派发时自动注入(`context.prompt`);Host / CLI 无消息来源时为 `undefined`。
220
+ *
221
+ * ```ts
222
+ * defineCommand({
223
+ * execute: async (context) => {
224
+ * const name = await context.prompt!.text('请输入你的名字');
225
+ * const age = await context.prompt!.number('请输入你的年龄');
226
+ * return `你好 ${name},你 ${age} 岁了`;
227
+ * },
228
+ * });
229
+ * ```
230
+ */
231
+ export interface CommandPrompt {
232
+ text(tips: string, options?: CommandPromptOptions & { readonly default?: string }): Promise<string>;
233
+ number(tips: string, options?: CommandPromptOptions & { readonly default?: number }): Promise<number>;
234
+ confirm(tips: string, options?: CommandPromptOptions & { readonly condition?: string; readonly default?: boolean }): Promise<boolean>;
235
+ list(tips: string, options?: CommandPromptListOptions): Promise<readonly (string | number | boolean)[]>;
236
+ pick<V = unknown>(tips: string, options: CommandPromptPickOptions<V>): Promise<V | readonly V[]>;
237
+ }
238
+
239
+ export type CommandPromptFactory = (source: unknown) => CommandPrompt | undefined;
240
+
196
241
  export interface CommandContext<
197
242
  TConfig = unknown,
198
243
  TInput extends CommandMessage = CommandMessage,
@@ -206,6 +251,10 @@ export interface CommandContext<
206
251
  * Host / `CommandIndex.execute` 等无消息路径可能为 `undefined`。
207
252
  */
208
253
  readonly input?: TInput;
254
+ /**
255
+ * 对话式交互输入。IM 派发时自动注入;无消息来源时为 `undefined`。
256
+ */
257
+ readonly prompt?: CommandPrompt;
209
258
  }
210
259
 
211
260
  export interface CommandDefinition<
@@ -371,6 +420,7 @@ export function createCommandContext(
371
420
  params: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
372
421
  input: unknown = undefined,
373
422
  segments: readonly Readonly<CommandSegment>[] = Object.freeze([]),
423
+ prompt?: CommandPrompt,
374
424
  ): CommandContext {
375
425
  const context = createCapabilityContext(snapshot, ownerId);
376
426
  const session = resolveCommandSession(input);
@@ -381,6 +431,7 @@ export function createCommandContext(
381
431
  params: Object.freeze({ ...params }),
382
432
  segments: freezeSegments(segments),
383
433
  ...(input !== undefined ? { input: input as CommandMessage } : {}),
434
+ ...(prompt !== undefined ? { prompt } : {}),
384
435
  });
385
436
  }
386
437
 
@@ -458,15 +509,10 @@ function resolveSender(
458
509
  return freezeSender(structured);
459
510
  }
460
511
 
461
- const $sender = (input as { readonly $sender?: Readonly<Record<string, unknown>> }).$sender;
462
- const id = input.sender?.id
463
- || (typeof $sender?.id === 'string' ? $sender.id : undefined)
464
- || firstString(metadata?.user_id, metadata?.userId);
512
+ const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
465
513
  if (!id) return undefined;
466
514
 
467
- const name = input.sender?.name
468
- || (typeof $sender?.name === 'string' ? $sender.name : undefined)
469
- || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
515
+ const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
470
516
  const role = resolveRoles(input, metadata);
471
517
 
472
518
  return Object.freeze({
@@ -490,18 +536,13 @@ function resolveRoles(
490
536
  if (trimmed && !roles.includes(trimmed)) roles.push(trimmed);
491
537
  };
492
538
 
493
- // IM Message 的 $sender 携带 enrich 快照(isMaster/isTrusted)和平台群角色(role)
494
- const $sender = (input as { readonly $sender?: Readonly<Record<string, unknown>> }).$sender;
495
-
496
- if ($sender?.isMaster === true || metadata?.isMaster === true) push('master');
497
- else if ($sender?.isTrusted === true || metadata?.isTrusted === true) push('trusted');
498
-
499
- push($sender?.role);
500
539
  if (Array.isArray(metadata?.roles)) {
501
540
  for (const item of metadata.roles) push(item);
502
541
  }
503
542
  push(metadata?.senderRole);
504
543
  push(metadata?.role);
544
+ if (metadata?.isMaster === true) push('master');
545
+ if (metadata?.isTrusted === true) push('trusted');
505
546
  if (roles.length === 0) roles.push('user');
506
547
  return Object.freeze(roles);
507
548
  }
package/src/provider.ts CHANGED
@@ -183,7 +183,13 @@ const commandFeature = defineFeatureProvider({
183
183
  },
184
184
  runtime: {
185
185
  project(slots, context) {
186
- return { value: new CommandIndex(slots, context.snapshot) };
186
+ const rootConfig = context.snapshot.config.get(context.snapshot.root) as
187
+ | Record<string, unknown> | undefined;
188
+ const rawKeyword = rootConfig?.menuKeyword;
189
+ const menu = rawKeyword === false || rawKeyword === ''
190
+ ? undefined
191
+ : { keyword: typeof rawKeyword === 'string' ? rawKeyword : '菜单' };
192
+ return { value: new CommandIndex(slots, context.snapshot, menu) };
187
193
  },
188
194
  },
189
195
  });