@zhin.js/command 1.0.7 → 1.0.10
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/README.md +13 -0
- package/lib/command-index.d.ts +4 -0
- package/lib/command-index.js +207 -54
- package/lib/definition.d.ts +70 -7
- package/lib/definition.js +71 -18
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permit.d.ts +5 -0
- package/lib/permit.js +5 -0
- package/lib/provider.js +9 -7
- package/package.json +4 -3
- package/src/command-index.ts +267 -54
- package/src/definition.ts +143 -24
- package/src/index.ts +10 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +10 -8
package/src/definition.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createCapabilityContext,
|
|
4
4
|
type CapabilityContext,
|
|
5
5
|
} from '@zhin.js/feature-kit';
|
|
6
|
+
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
6
7
|
|
|
7
8
|
const commandBrand = 'zhin.command/1' as const;
|
|
8
9
|
|
|
@@ -29,6 +30,29 @@ export type CommandParameterValue =
|
|
|
29
30
|
| Readonly<Record<string, unknown>>
|
|
30
31
|
| null;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 可从运行时会话上下文动态解析的参数值。
|
|
35
|
+
*
|
|
36
|
+
* 静态值直接使用;函数值在命令派发时接收 {@link CommandSession},
|
|
37
|
+
* 返回最终的 {@link CommandParameterValue}。适用于 shortcut 预填
|
|
38
|
+
* 和 params.default。
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* defineCommand({
|
|
42
|
+
* params: {
|
|
43
|
+
* user_id: { type: 'string', default: (s) => s.sender?.id ?? '' },
|
|
44
|
+
* },
|
|
45
|
+
* shortcut: {
|
|
46
|
+
* '查看我的信息': { user_id: (s) => s.sender?.id ?? '' },
|
|
47
|
+
* },
|
|
48
|
+
* execute: ({ params }) => `profile:${params.user_id}`,
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export type CommandDynamicValue =
|
|
53
|
+
| CommandParameterValue
|
|
54
|
+
| ((session: CommandSession) => CommandParameterValue);
|
|
55
|
+
|
|
32
56
|
export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set([
|
|
33
57
|
'string',
|
|
34
58
|
'number',
|
|
@@ -53,7 +77,7 @@ export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set(
|
|
|
53
77
|
*/
|
|
54
78
|
export interface CommandParamSchema {
|
|
55
79
|
readonly type: CommandParameterType;
|
|
56
|
-
readonly default?:
|
|
80
|
+
readonly default?: CommandDynamicValue;
|
|
57
81
|
readonly description?: string;
|
|
58
82
|
}
|
|
59
83
|
|
|
@@ -66,7 +90,7 @@ export interface CommandSegment {
|
|
|
66
90
|
export interface CommandParameterDefinition {
|
|
67
91
|
readonly name: string;
|
|
68
92
|
readonly type: CommandParameterType;
|
|
69
|
-
readonly defaultValue?:
|
|
93
|
+
readonly defaultValue?: CommandDynamicValue;
|
|
70
94
|
/** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
|
|
71
95
|
readonly optional?: boolean;
|
|
72
96
|
/** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
|
|
@@ -104,7 +128,7 @@ export interface CommandConversation {
|
|
|
104
128
|
readonly kind: 'private' | 'group' | 'channel';
|
|
105
129
|
readonly id: string;
|
|
106
130
|
readonly parent?: Readonly<{
|
|
107
|
-
readonly kind: 'group' | 'channel';
|
|
131
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
108
132
|
readonly id: string;
|
|
109
133
|
}>;
|
|
110
134
|
readonly threadId?: string;
|
|
@@ -119,14 +143,35 @@ export interface CommandConversation {
|
|
|
119
143
|
export interface CommandMessage {
|
|
120
144
|
readonly conversation: CommandConversation;
|
|
121
145
|
readonly content: string;
|
|
122
|
-
/**
|
|
123
|
-
readonly sender?: string;
|
|
146
|
+
/** 发送者(结构化视图见 CommandContext.sender)。 */
|
|
147
|
+
readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
|
|
124
148
|
readonly id?: string;
|
|
125
149
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
126
150
|
/** 若上游已结构化,优先采用。 */
|
|
127
151
|
readonly scene?: CommandScene;
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
// 方法式声明(而非属性式函数类型):方法参数双变,runtime `Message` 的
|
|
153
|
+
// `$reply(content: SendContent)` 等才能鸭式兼容本契约(属性式是抗变,会报错)。
|
|
154
|
+
$reply?(content: unknown): Promise<unknown>;
|
|
155
|
+
$replyFrom?(requester: string, content: unknown): Promise<unknown>;
|
|
156
|
+
/** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
|
|
157
|
+
$sendTo?(
|
|
158
|
+
conversation: {
|
|
159
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
160
|
+
readonly id: string;
|
|
161
|
+
readonly parent?: Readonly<{ readonly kind: 'private' | 'group' | 'channel'; readonly id: string }>;
|
|
162
|
+
readonly threadId?: string;
|
|
163
|
+
},
|
|
164
|
+
content: unknown,
|
|
165
|
+
): Promise<unknown>;
|
|
166
|
+
/** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
|
|
167
|
+
$replyToPrivate?(
|
|
168
|
+
content: unknown,
|
|
169
|
+
from?: boolean | { readonly kind: 'group' | 'channel'; readonly id: string },
|
|
170
|
+
): Promise<unknown>;
|
|
171
|
+
/** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
|
|
172
|
+
$replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
|
|
173
|
+
/** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
|
|
174
|
+
$replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
|
|
130
175
|
}
|
|
131
176
|
|
|
132
177
|
/**
|
|
@@ -176,6 +221,21 @@ export interface CommandDefinition<
|
|
|
176
221
|
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
177
222
|
*/
|
|
178
223
|
readonly params?: Readonly<Record<string, CommandParamSchema>>;
|
|
224
|
+
/**
|
|
225
|
+
* 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
|
|
226
|
+
* owner 前缀;不打破子插件命名空间。
|
|
227
|
+
*/
|
|
228
|
+
readonly alias?: readonly string[];
|
|
229
|
+
/**
|
|
230
|
+
* 内置 permit DSL(AND)。单项内逗号为 OR。
|
|
231
|
+
* 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
|
|
232
|
+
*/
|
|
233
|
+
readonly permit?: readonly string[];
|
|
234
|
+
/**
|
|
235
|
+
* 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
|
|
236
|
+
* 可打破 owner 命名空间。
|
|
237
|
+
*/
|
|
238
|
+
readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
|
|
179
239
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
180
240
|
}
|
|
181
241
|
|
|
@@ -213,9 +273,58 @@ export function defineCommand<
|
|
|
213
273
|
}
|
|
214
274
|
}
|
|
215
275
|
}
|
|
276
|
+
validateCommandAlias(definition.alias);
|
|
277
|
+
validateCommandPermit(definition.permit);
|
|
278
|
+
validateCommandShortcutShape(definition.shortcut);
|
|
216
279
|
return Object.freeze({ $feature: commandBrand, ...definition });
|
|
217
280
|
}
|
|
218
281
|
|
|
282
|
+
function validateCommandAlias(alias: readonly string[] | undefined): void {
|
|
283
|
+
if (alias === undefined) return;
|
|
284
|
+
if (!Array.isArray(alias)) {
|
|
285
|
+
throw new TypeError('Command alias must be a readonly string[]');
|
|
286
|
+
}
|
|
287
|
+
for (const [index, entry] of alias.entries()) {
|
|
288
|
+
if (typeof entry !== 'string') {
|
|
289
|
+
throw new TypeError(`Command alias[${index}] must be a string`);
|
|
290
|
+
}
|
|
291
|
+
const tokens = entry.trim().split(/\s+/u).filter(Boolean);
|
|
292
|
+
if (tokens.length === 0) {
|
|
293
|
+
throw new TypeError(`Command alias[${index}] must contain at least one token`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validateCommandPermit(permit: readonly string[] | undefined): void {
|
|
299
|
+
if (permit === undefined) return;
|
|
300
|
+
if (!Array.isArray(permit)) {
|
|
301
|
+
throw new TypeError('Command permit must be a readonly string[]');
|
|
302
|
+
}
|
|
303
|
+
for (const [index, entry] of permit.entries()) {
|
|
304
|
+
if (typeof entry !== 'string') {
|
|
305
|
+
throw new TypeError(`Command permit[${index}] must be a string`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
assertPermitSyntax(permit);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function validateCommandShortcutShape(
|
|
312
|
+
shortcut: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>> | undefined,
|
|
313
|
+
): void {
|
|
314
|
+
if (shortcut === undefined) return;
|
|
315
|
+
if (!shortcut || typeof shortcut !== 'object' || Array.isArray(shortcut)) {
|
|
316
|
+
throw new TypeError('Command shortcut must be a Record<string, Record<string, value>>');
|
|
317
|
+
}
|
|
318
|
+
for (const [trigger, params] of Object.entries(shortcut)) {
|
|
319
|
+
if (!trigger.trim()) {
|
|
320
|
+
throw new TypeError('Command shortcut keys must be non-empty after trim');
|
|
321
|
+
}
|
|
322
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
323
|
+
throw new TypeError(`Command shortcut[${JSON.stringify(trigger)}] must be a params Record`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
219
328
|
export function bindCommandParameter<
|
|
220
329
|
TConfig,
|
|
221
330
|
TResult,
|
|
@@ -239,6 +348,22 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
|
|
|
239
348
|
return definition as CommandDefinition;
|
|
240
349
|
}
|
|
241
350
|
|
|
351
|
+
/**
|
|
352
|
+
* 将动态参数值(可能包含函数)批量解析为静态值。
|
|
353
|
+
* 函数值接收从 `source`(通常是 IM Runtime `Message`)解析出的 {@link CommandSession}。
|
|
354
|
+
*/
|
|
355
|
+
export function resolveDynamicParams(
|
|
356
|
+
params: Readonly<Record<string, CommandDynamicValue>>,
|
|
357
|
+
source: unknown,
|
|
358
|
+
): Readonly<Record<string, CommandParameterValue>> {
|
|
359
|
+
const session = resolveCommandSession(source);
|
|
360
|
+
const resolved: Record<string, CommandParameterValue> = {};
|
|
361
|
+
for (const [key, value] of Object.entries(params)) {
|
|
362
|
+
resolved[key] = typeof value === 'function' ? value(session) : value;
|
|
363
|
+
}
|
|
364
|
+
return Object.freeze(resolved);
|
|
365
|
+
}
|
|
366
|
+
|
|
242
367
|
export function createCommandContext(
|
|
243
368
|
snapshot: RuntimeSnapshot,
|
|
244
369
|
ownerId: PluginId,
|
|
@@ -271,9 +396,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
271
396
|
: undefined;
|
|
272
397
|
|
|
273
398
|
const adapter = input.conversation.endpoint.adapter || undefined;
|
|
274
|
-
const endpoint =
|
|
275
|
-
? metadata.endpoint
|
|
276
|
-
: undefined;
|
|
399
|
+
const endpoint = (input as { endpointId?: string }).endpointId
|
|
400
|
+
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
277
401
|
|
|
278
402
|
const scene = resolveScene(input, metadata);
|
|
279
403
|
const sender = resolveSender(input, metadata);
|
|
@@ -307,11 +431,8 @@ function resolveScene(
|
|
|
307
431
|
}
|
|
308
432
|
|
|
309
433
|
const conversation = input.conversation;
|
|
310
|
-
const type =
|
|
311
|
-
|
|
312
|
-
|| conversation.kind;
|
|
313
|
-
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
314
|
-
|| conversation.id;
|
|
434
|
+
const type = conversation.kind;
|
|
435
|
+
const id = conversation.id;
|
|
315
436
|
if (!type || !id) return undefined;
|
|
316
437
|
|
|
317
438
|
const name = firstString(
|
|
@@ -336,18 +457,12 @@ function resolveSender(
|
|
|
336
457
|
if (isCommandSender(structured)) {
|
|
337
458
|
return freezeSender(structured);
|
|
338
459
|
}
|
|
339
|
-
// 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
|
|
340
|
-
if (isCommandSender(input.sender)) {
|
|
341
|
-
return freezeSender(input.sender);
|
|
342
|
-
}
|
|
343
460
|
|
|
344
|
-
const id =
|
|
345
|
-
? input.sender
|
|
346
|
-
: firstString(metadata?.user_id, metadata?.userId);
|
|
461
|
+
const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
|
|
347
462
|
if (!id) return undefined;
|
|
348
463
|
|
|
349
|
-
const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
350
|
-
const role = resolveRoles(metadata);
|
|
464
|
+
const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
465
|
+
const role = resolveRoles(input, metadata);
|
|
351
466
|
|
|
352
467
|
return Object.freeze({
|
|
353
468
|
id,
|
|
@@ -357,8 +472,12 @@ function resolveSender(
|
|
|
357
472
|
}
|
|
358
473
|
|
|
359
474
|
function resolveRoles(
|
|
475
|
+
input: CommandMessage,
|
|
360
476
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
361
477
|
): readonly string[] {
|
|
478
|
+
if (input.sender?.roles?.length) {
|
|
479
|
+
return Object.freeze([...input.sender.roles]);
|
|
480
|
+
}
|
|
362
481
|
const roles: string[] = [];
|
|
363
482
|
const push = (value: unknown) => {
|
|
364
483
|
if (typeof value !== 'string') return;
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export * from './command-index.js';
|
|
2
2
|
export * from './definition.js';
|
|
3
|
+
export {
|
|
4
|
+
assertBuiltinPermits,
|
|
5
|
+
checkBuiltinPermit,
|
|
6
|
+
checkBuiltinPermitList,
|
|
7
|
+
isBuiltinPermit,
|
|
8
|
+
isPlatformPermit,
|
|
9
|
+
parsePermitName,
|
|
10
|
+
type ParsedPermit,
|
|
11
|
+
type PermitKind,
|
|
12
|
+
} from './permit.js';
|
|
3
13
|
export {
|
|
4
14
|
CommandPathSyntaxError,
|
|
5
15
|
commandFeatureId,
|
package/src/permit.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
|
+
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
type PermitKind,
|
|
7
|
+
type ParsedPermit,
|
|
8
|
+
parsePermitName,
|
|
9
|
+
isBuiltinPermit,
|
|
10
|
+
isPlatformPermit,
|
|
11
|
+
assertPermitSyntax as assertBuiltinPermits,
|
|
12
|
+
checkBuiltinPermit,
|
|
13
|
+
checkBuiltinPermitList,
|
|
14
|
+
} from '@zhin.js/permission';
|
package/src/provider.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename, join, parse, sep } from 'node:path';
|
|
2
|
-
import { featureId } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import {
|
|
4
4
|
defineFeatureProvider,
|
|
5
5
|
type DiscoveryContext,
|
|
@@ -54,7 +54,7 @@ async function* discoverCommandDirectory(
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
for (const entry of entries) {
|
|
57
|
-
if (entry.kind === 'directory' &&
|
|
57
|
+
if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
|
|
58
58
|
yield* discoverCommandDirectory(
|
|
59
59
|
context,
|
|
60
60
|
join(directory, entry.name),
|
|
@@ -74,10 +74,6 @@ async function* discoverCommandDirectory(
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function isCommandSegment(value: string): boolean {
|
|
78
|
-
return /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
77
|
interface ParsedCommandFile {
|
|
82
78
|
readonly localSegment: string;
|
|
83
79
|
readonly parameter?: CommandParameterHint;
|
|
@@ -101,9 +97,15 @@ const dynamicCommandFilePatterns: ReadonlyArray<{
|
|
|
101
97
|
{ pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
|
|
102
98
|
];
|
|
103
99
|
|
|
100
|
+
const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
|
|
101
|
+
|
|
104
102
|
function parseCommandFile(value: string): ParsedCommandFile | undefined {
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
// 静态段:ASCII kebab(hello.ts)或 Unicode 名(赞我.ts);与 isCapabilityLocalSegment 对齐。
|
|
104
|
+
if (commandModuleExtension.test(value)) {
|
|
105
|
+
const localSegment = parse(value).name;
|
|
106
|
+
if (isCapabilityLocalSegment(localSegment)) {
|
|
107
|
+
return { localSegment };
|
|
108
|
+
}
|
|
107
109
|
}
|
|
108
110
|
for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
|
|
109
111
|
const match = pattern.exec(value);
|