@zhin.js/command 1.0.1 → 1.0.3

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/src/definition.ts CHANGED
@@ -6,8 +6,33 @@ import {
6
6
 
7
7
  const commandBrand = 'zhin.command/1' as const;
8
8
 
9
- export type CommandParameterType = 'string' | 'number' | 'boolean';
10
- export type CommandParameterValue = string | number | boolean;
9
+ export type CommandParameterType =
10
+ | 'string'
11
+ | 'number'
12
+ | 'integer'
13
+ | 'float'
14
+ | 'boolean'
15
+ | 'word'
16
+ | 'text'
17
+ | 'mention'
18
+ | 'image'
19
+ | 'face'
20
+ | 'reply'
21
+ | 'forward'
22
+ | 'dice'
23
+ | 'rps';
24
+ export type CommandParameterValue =
25
+ | string
26
+ | number
27
+ | boolean
28
+ | Readonly<Record<string, unknown>>
29
+ | null;
30
+
31
+ /** Minimal structural contract shared with canonical IM segments. */
32
+ export interface CommandSegment {
33
+ readonly type: string | { readonly name: string };
34
+ readonly data: Readonly<Record<string, unknown>>;
35
+ }
11
36
 
12
37
  export interface CommandParameterDefinition {
13
38
  readonly name: string;
@@ -15,21 +40,106 @@ export interface CommandParameterDefinition {
15
40
  readonly defaultValue?: CommandParameterValue;
16
41
  }
17
42
 
18
- export interface CommandContext<TConfig = unknown, TInput = unknown>
19
- extends CapabilityContext<TConfig> {
43
+ /** 场景:群 / 私聊 / 频道等。 */
44
+ export interface CommandScene {
45
+ readonly id: string;
46
+ readonly type: string;
47
+ readonly name?: string;
48
+ }
49
+
50
+ /**
51
+ * 发送者。
52
+ * `role` 为角色列表(如 `user` / `trusted` / `master`,以及平台侧 `owner` / `admin` 等)。
53
+ */
54
+ export interface CommandSender {
55
+ readonly id: string;
56
+ readonly name?: string;
57
+ readonly role: readonly string[];
58
+ }
59
+
60
+ /**
61
+ * 命令侧入站消息契约。
62
+ *
63
+ * `@zhin.js/core/runtime` 的 `Message` 结构兼容本接口(duck typing)。
64
+ * 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
65
+ */
66
+ export interface CommandMessage {
67
+ readonly adapter: string;
68
+ readonly target: string;
69
+ readonly content: string;
70
+ /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
71
+ readonly sender?: string;
72
+ readonly id?: string;
73
+ readonly metadata?: Readonly<Record<string, unknown>>;
74
+ /** 若上游已结构化,优先采用。 */
75
+ readonly scene?: CommandScene;
76
+ readonly $reply?: (content: unknown) => Promise<unknown>;
77
+ readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
78
+ }
79
+
80
+ /**
81
+ * IM 入站快捷字段。
82
+ * 有 `CommandMessage` 来源时由 {@link resolveCommandSession} 填充;
83
+ * `CommandIndex.execute(name)` 等无消息路径下为 `undefined`。
84
+ */
85
+ export interface CommandSession {
86
+ /**
87
+ * 适配器插件实例 id(CapabilityId 的 owner 段,如 `root/icqq`)。
88
+ * 与 `snapshot.config.get(adapter)` 对齐。
89
+ */
90
+ readonly adapter?: string;
91
+ /** Endpoint 名(`metadata.endpoint`)。 */
92
+ readonly endpoint?: string;
93
+ /** 场景对象(id / type / name)。 */
94
+ readonly scene?: CommandScene;
95
+ /** 发送者对象(id / name / role[])。 */
96
+ readonly sender?: CommandSender;
97
+ }
98
+
99
+ export interface CommandContext<
100
+ TConfig = unknown,
101
+ TInput extends CommandMessage = CommandMessage,
102
+ > extends CapabilityContext<TConfig>, CommandSession {
20
103
  readonly args: readonly string[];
21
104
  readonly params: Readonly<Record<string, CommandParameterValue>>;
22
- readonly input: TInput;
105
+ /** Structured arguments left after the command pattern was consumed. */
106
+ readonly segments: readonly Readonly<CommandSegment>[];
107
+ /**
108
+ * 派发来源。IM 命中时为 Runtime `Message`(满足 {@link CommandMessage});
109
+ * Host / `CommandIndex.execute` 等无消息路径可能为 `undefined`。
110
+ */
111
+ readonly input?: TInput;
23
112
  }
24
113
 
25
- export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput = unknown> {
114
+ export interface CommandDefinition<
115
+ TConfig = unknown,
116
+ TResult = unknown,
117
+ TInput extends CommandMessage = CommandMessage,
118
+ > {
26
119
  readonly $feature: typeof commandBrand;
27
120
  readonly $parameter?: CommandParameterDefinition;
28
121
  readonly description?: string;
29
122
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
30
123
  }
31
124
 
32
- export function defineCommand<TConfig = unknown, TResult = unknown, TInput = unknown>(
125
+ declare module '@zhin.js/plugin-runtime' {
126
+ interface PluginSetupContext<TConfig> {
127
+ addCommand<TResult = unknown, TInput extends CommandMessage = CommandMessage>(
128
+ localName: string,
129
+ definition: CommandDefinition<TConfig, TResult, TInput>,
130
+ ): void;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 定义一个命令模块(`commands/` 约定目录下默认导出)。
136
+ * @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
137
+ */
138
+ export function defineCommand<
139
+ TConfig = unknown,
140
+ TResult = unknown,
141
+ TInput extends CommandMessage = CommandMessage,
142
+ >(
33
143
  definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>,
34
144
  ): Readonly<CommandDefinition<TConfig, TResult, TInput>> {
35
145
  if (typeof definition.execute !== 'function') {
@@ -38,7 +148,11 @@ export function defineCommand<TConfig = unknown, TResult = unknown, TInput = unk
38
148
  return Object.freeze({ $feature: commandBrand, ...definition });
39
149
  }
40
150
 
41
- export function bindCommandParameter<TConfig, TResult, TInput>(
151
+ export function bindCommandParameter<
152
+ TConfig,
153
+ TResult,
154
+ TInput extends CommandMessage,
155
+ >(
42
156
  definition: CommandDefinition<TConfig, TResult, TInput>,
43
157
  parameter: CommandParameterDefinition | undefined,
44
158
  ): Readonly<CommandDefinition<TConfig, TResult, TInput>> {
@@ -63,12 +177,191 @@ export function createCommandContext(
63
177
  args: readonly string[],
64
178
  params: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
65
179
  input: unknown = undefined,
180
+ segments: readonly Readonly<CommandSegment>[] = Object.freeze([]),
66
181
  ): CommandContext {
67
182
  const context = createCapabilityContext(snapshot, ownerId);
183
+ const session = resolveCommandSession(input);
68
184
  return Object.freeze({
69
185
  ...context,
186
+ ...session,
70
187
  args: Object.freeze([...args]),
71
188
  params: Object.freeze({ ...params }),
72
- input,
189
+ segments: freezeSegments(segments),
190
+ ...(input !== undefined ? { input: input as CommandMessage } : {}),
191
+ });
192
+ }
193
+
194
+ /**
195
+ * 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
196
+ * 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
197
+ */
198
+ export function resolveCommandSession(input: unknown): CommandSession {
199
+ if (!isCommandMessageLike(input)) return Object.freeze({});
200
+
201
+ const metadata = input.metadata && typeof input.metadata === 'object'
202
+ ? input.metadata as Readonly<Record<string, unknown>>
203
+ : undefined;
204
+
205
+ const adapter = input.adapter.split('\0')[0] || undefined;
206
+ const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
207
+ ? metadata.endpoint
208
+ : undefined;
209
+
210
+ const scene = resolveScene(input, metadata);
211
+ const sender = resolveSender(input, metadata);
212
+
213
+ return Object.freeze({
214
+ ...(adapter ? { adapter } : {}),
215
+ ...(endpoint !== undefined ? { endpoint } : {}),
216
+ ...(scene !== undefined ? { scene } : {}),
217
+ ...(sender !== undefined ? { sender } : {}),
218
+ });
219
+ }
220
+
221
+ function isCommandMessageLike(input: unknown): input is CommandMessage {
222
+ if (!input || typeof input !== 'object') return false;
223
+ const value = input as Partial<CommandMessage>;
224
+ return typeof value.adapter === 'string'
225
+ && typeof value.target === 'string'
226
+ && typeof value.content === 'string';
227
+ }
228
+
229
+ function resolveScene(
230
+ input: CommandMessage,
231
+ metadata: Readonly<Record<string, unknown>> | undefined,
232
+ ): CommandScene | undefined {
233
+ if (isCommandScene(input.scene)) {
234
+ return Object.freeze({
235
+ id: input.scene.id,
236
+ type: input.scene.type,
237
+ ...(input.scene.name !== undefined ? { name: input.scene.name } : {}),
238
+ });
239
+ }
240
+
241
+ const parsed = parseTarget(input.target);
242
+ const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
243
+ || (typeof metadata?.type === 'string' && metadata.type)
244
+ || parsed?.type;
245
+ const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
246
+ || parsed?.id;
247
+ if (!type || !id) return undefined;
248
+
249
+ const name = firstString(
250
+ metadata?.channelName,
251
+ metadata?.group_name,
252
+ metadata?.groupName,
253
+ metadata?.sceneName,
254
+ );
255
+
256
+ return Object.freeze({
257
+ id,
258
+ type,
259
+ ...(name !== undefined ? { name } : {}),
73
260
  });
74
261
  }
262
+
263
+ function resolveSender(
264
+ input: CommandMessage,
265
+ metadata: Readonly<Record<string, unknown>> | undefined,
266
+ ): CommandSender | undefined {
267
+ const structured = (input as { readonly from?: unknown }).from;
268
+ if (isCommandSender(structured)) {
269
+ return freezeSender(structured);
270
+ }
271
+ // 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
272
+ if (isCommandSender(input.sender)) {
273
+ return freezeSender(input.sender);
274
+ }
275
+
276
+ const id = typeof input.sender === 'string' && input.sender
277
+ ? input.sender
278
+ : firstString(metadata?.user_id, metadata?.userId);
279
+ if (!id) return undefined;
280
+
281
+ const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
282
+ const role = resolveRoles(metadata);
283
+
284
+ return Object.freeze({
285
+ id,
286
+ ...(name !== undefined ? { name } : {}),
287
+ role,
288
+ });
289
+ }
290
+
291
+ function resolveRoles(
292
+ metadata: Readonly<Record<string, unknown>> | undefined,
293
+ ): readonly string[] {
294
+ const roles: string[] = [];
295
+ const push = (value: unknown) => {
296
+ if (typeof value !== 'string') return;
297
+ const trimmed = value.trim();
298
+ if (trimmed && !roles.includes(trimmed)) roles.push(trimmed);
299
+ };
300
+
301
+ if (Array.isArray(metadata?.roles)) {
302
+ for (const item of metadata.roles) push(item);
303
+ }
304
+ push(metadata?.senderRole);
305
+ push(metadata?.role);
306
+ if (metadata?.isMaster === true) push('master');
307
+ if (metadata?.isTrusted === true) push('trusted');
308
+ if (roles.length === 0) roles.push('user');
309
+ return Object.freeze(roles);
310
+ }
311
+
312
+ function parseTarget(target: string): { readonly type: string; readonly id: string } | undefined {
313
+ const parts = target.split(':').filter(Boolean);
314
+ if (parts.length < 2) return undefined;
315
+ const kind = parts[0];
316
+ if (kind === 'channel' && parts.length >= 3) {
317
+ return { type: 'channel', id: parts[parts.length - 1]! };
318
+ }
319
+ if (kind === 'temp' && parts.length >= 3) {
320
+ return { type: 'private', id: parts[parts.length - 1]! };
321
+ }
322
+ return { type: kind!, id: parts.slice(1).join(':') };
323
+ }
324
+
325
+ function isCommandScene(value: unknown): value is CommandScene {
326
+ if (!value || typeof value !== 'object') return false;
327
+ const scene = value as Partial<CommandScene>;
328
+ return typeof scene.id === 'string'
329
+ && scene.id.length > 0
330
+ && typeof scene.type === 'string'
331
+ && scene.type.length > 0;
332
+ }
333
+
334
+ function isCommandSender(value: unknown): value is CommandSender {
335
+ if (!value || typeof value !== 'object') return false;
336
+ const sender = value as Partial<CommandSender>;
337
+ return typeof sender.id === 'string'
338
+ && sender.id.length > 0
339
+ && Array.isArray(sender.role)
340
+ && sender.role.every((item) => typeof item === 'string');
341
+ }
342
+
343
+ function freezeSender(sender: CommandSender): CommandSender {
344
+ return Object.freeze({
345
+ id: sender.id,
346
+ ...(sender.name !== undefined ? { name: sender.name } : {}),
347
+ role: Object.freeze([...sender.role]),
348
+ });
349
+ }
350
+
351
+ function firstString(...values: unknown[]): string | undefined {
352
+ for (const value of values) {
353
+ if (typeof value === 'string' && value.trim()) return value;
354
+ }
355
+ return undefined;
356
+ }
357
+
358
+ function freezeSegments(
359
+ segments: readonly Readonly<CommandSegment>[],
360
+ ): readonly Readonly<CommandSegment>[] {
361
+ return Object.freeze(segments.map((segment) => Object.freeze({
362
+ type: typeof segment.type === 'string'
363
+ ? segment.type
364
+ : Object.freeze({ name: segment.type.name }),
365
+ data: Object.freeze({ ...segment.data }),
366
+ })));
367
+ }
package/src/provider.ts CHANGED
@@ -68,7 +68,24 @@ interface ParsedCommandFile {
68
68
  }
69
69
 
70
70
  const dynamicCommandFilePattern =
71
- /^\[([a-z][a-zA-Z0-9]*):(string|number|boolean)(?:=([^\]]*))?\]\.tsx?$/;
71
+ /^\[([a-z][a-zA-Z0-9]*):([a-z][a-z0-9-]*)(?:=([^\]]*))?\]\.tsx?$/;
72
+
73
+ const commandParameterTypes = new Set<CommandParameterType>([
74
+ 'string',
75
+ 'number',
76
+ 'integer',
77
+ 'float',
78
+ 'boolean',
79
+ 'word',
80
+ 'text',
81
+ 'mention',
82
+ 'image',
83
+ 'face',
84
+ 'reply',
85
+ 'forward',
86
+ 'dice',
87
+ 'rps',
88
+ ]);
72
89
 
73
90
  function parseCommandFile(value: string): ParsedCommandFile | undefined {
74
91
  if (/^[a-z0-9][a-z0-9-]*\.tsx?$/.test(value)) {
@@ -76,10 +93,11 @@ function parseCommandFile(value: string): ParsedCommandFile | undefined {
76
93
  }
77
94
  const match = dynamicCommandFilePattern.exec(value);
78
95
  if (match) {
79
- const [, name, type, rawDefault] = match as RegExpExecArray & {
80
- readonly 1: string;
81
- readonly 2: CommandParameterType;
82
- };
96
+ const [, name, rawType, rawDefault] = match;
97
+ if (!name || !rawType || !commandParameterTypes.has(rawType as CommandParameterType)) {
98
+ throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
99
+ }
100
+ const type = rawType as CommandParameterType;
83
101
  // Metadata can change during HMR while $name keeps the Capability identity stable.
84
102
  const parameter = rawDefault === undefined
85
103
  ? { name, type }
@@ -98,12 +116,32 @@ function parseParameterValue(
98
116
  value: string,
99
117
  source: string,
100
118
  ): CommandParameterValue {
101
- if (type === 'string') return value;
102
- if (type === 'number') {
119
+ if (type === 'string' || type === 'word' || type === 'text') return value;
120
+ if (type === 'number' || type === 'integer' || type === 'float') {
103
121
  const number = Number(value);
104
- if (value.trim().length > 0 && Number.isFinite(number)) return number;
105
- } else if (value === 'true' || value === 'false') {
106
- return value === 'true';
122
+ if (
123
+ value.trim().length > 0
124
+ && Number.isFinite(number)
125
+ && (type !== 'integer' || Number.isInteger(number))
126
+ && (type !== 'float' || value.includes('.'))
127
+ ) return number;
128
+ throw new CommandPathSyntaxError(
129
+ source,
130
+ `default for ${name}:${type} is invalid`,
131
+ );
132
+ }
133
+ if (type === 'boolean') {
134
+ if (value === 'true' || value === 'false') return value === 'true';
135
+ throw new CommandPathSyntaxError(
136
+ source,
137
+ `default for ${name}:${type} is invalid`,
138
+ );
139
+ }
140
+ if (isStructuredParameter(type)) {
141
+ throw new CommandPathSyntaxError(
142
+ source,
143
+ `default for structured parameter ${name}:${type} is not supported`,
144
+ );
107
145
  }
108
146
  throw new CommandPathSyntaxError(
109
147
  source,
@@ -111,8 +149,18 @@ function parseParameterValue(
111
149
  );
112
150
  }
113
151
 
152
+ function isStructuredParameter(type: CommandParameterType): boolean {
153
+ return type === 'mention'
154
+ || type === 'image'
155
+ || type === 'face'
156
+ || type === 'reply'
157
+ || type === 'forward'
158
+ || type === 'dice'
159
+ || type === 'rps';
160
+ }
161
+
114
162
  export class CommandPathSyntaxError extends TypeError {
115
- constructor(file: string, detail = 'expected [name:string|number|boolean=default].ts(x)') {
163
+ constructor(file: string, detail = 'expected [name:type=default].ts(x)') {
116
164
  super(`Invalid Command path ${file}: ${detail}`);
117
165
  this.name = 'CommandPathSyntaxError';
118
166
  }
@@ -122,6 +170,7 @@ const commandFeature = defineFeatureProvider({
122
170
  protocol: 1,
123
171
  id: commandFeatureId,
124
172
  authoring: {
173
+ setupMethod: 'addCommand',
125
174
  conventions: [commandFiles],
126
175
  validate: parseCommandDefinition,
127
176
  },