@zhin.js/command 1.0.2 → 1.0.4
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 +46 -8
- package/lib/command-index.d.ts +3 -2
- package/lib/command-index.js +147 -56
- package/lib/definition.d.ts +87 -8
- package/lib/definition.js +161 -2
- package/lib/provider.d.ts +1 -1
- package/lib/provider.js +76 -10
- package/package.json +4 -3
- package/src/command-index.ts +189 -66
- package/src/definition.ts +302 -9
- package/src/provider.ts +88 -13
package/README.md
CHANGED
|
@@ -1,20 +1,58 @@
|
|
|
1
1
|
# @zhin.js/command
|
|
2
2
|
|
|
3
|
-
Zhin Plugin Runtime
|
|
4
|
-
|
|
3
|
+
Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*.ts(x)`,将插件树
|
|
4
|
+
路径与文件路径投影为命令,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
|
|
5
|
+
|
|
6
|
+
## Authoring
|
|
5
7
|
|
|
6
8
|
```ts
|
|
9
|
+
// commands/gh/issue/list.ts -> gh issue list
|
|
7
10
|
import { defineCommand } from '@zhin.js/command';
|
|
8
11
|
|
|
9
12
|
export default defineCommand({
|
|
10
|
-
description: '
|
|
11
|
-
execute: ({ args }) => args.join('
|
|
13
|
+
description: 'List GitHub issues',
|
|
14
|
+
execute: ({ args }) => `issues:${args.join(',')}`,
|
|
12
15
|
});
|
|
13
16
|
```
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
最后一个文件名可以声明参数:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
commands/gh/pr/[title:string=defaultTitle].ts -> gh pr [title]
|
|
22
|
+
commands/upload/[asset:image].ts -> upload <asset>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
|
|
26
|
+
类型包括 `mention`、`image`、`face`、`reply`、`forward`、`dice`、`rps`。结构化类型
|
|
27
|
+
直接从对应 segment 取值,不能声明文件名默认值。
|
|
28
|
+
|
|
29
|
+
`execute()` 收到冻结的 `CommandContext`:
|
|
30
|
+
|
|
31
|
+
- `params`:路由参数的类型化结果。
|
|
32
|
+
- `args`:匹配后剩余文本按空白切分的兼容视图。
|
|
33
|
+
- `segments`:匹配后剩余的结构化段,媒体和 mention 不会丢失。
|
|
34
|
+
- `config` / `use()` / `owner` / `generation`:owner-scoped Runtime 上下文。
|
|
35
|
+
- `input`:派发来源;IM 中为满足 `CommandMessage` 的 Runtime `Message`。
|
|
36
|
+
- `adapter` / `endpoint`:适配器实例 id 与 endpoint 名。
|
|
37
|
+
- `scene`:`{ id, type, name? }` 场景对象。
|
|
38
|
+
- `sender`:`{ id, name?, role: string[] }` 发送者对象。
|
|
17
39
|
|
|
18
|
-
|
|
40
|
+
单文件插件可在 `setup({ addCommand })` 中调用
|
|
41
|
+
`addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
|
|
42
|
+
可获得单命令 HMR。
|
|
43
|
+
|
|
44
|
+
## Runtime
|
|
45
|
+
|
|
46
|
+
definition 在 import 时不注册全局状态。Feature provider 在 generation prepare 阶段完成
|
|
47
|
+
发现、校验和 `CommandIndex` 投影;静态路由优先于动态路由,同形动态路由在启动期拒绝。
|
|
48
|
+
生产 manifest 指向 `lib/provider.js`。
|
|
49
|
+
|
|
50
|
+
验证:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm --filter @zhin.js/command build
|
|
54
|
+
pnpm --filter @zhin.js/command test
|
|
55
|
+
```
|
|
19
56
|
|
|
20
|
-
|
|
57
|
+
完整用户契约见[命令创作指南](../../../docs/authoring/commands.md),架构迁移背景见
|
|
58
|
+
[Plugin Runtime 原位迁移](../../../docs/architecture/target-implementation/in-place-migration.md)。
|
package/lib/command-index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CapabilitySlot, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
|
-
import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType } from './definition.js';
|
|
2
|
+
import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment } from './definition.js';
|
|
3
3
|
export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
4
4
|
readonly required: boolean;
|
|
5
5
|
}
|
|
@@ -15,6 +15,7 @@ export interface CommandDispatchResult {
|
|
|
15
15
|
readonly owner?: PluginId;
|
|
16
16
|
readonly value?: unknown;
|
|
17
17
|
}
|
|
18
|
+
export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
|
|
18
19
|
export declare class CommandIndex {
|
|
19
20
|
#private;
|
|
20
21
|
private readonly snapshot;
|
|
@@ -23,7 +24,7 @@ export declare class CommandIndex {
|
|
|
23
24
|
list(): readonly CommandDescriptor[];
|
|
24
25
|
has(name: string): boolean;
|
|
25
26
|
execute(name: string, args?: readonly string[]): Promise<unknown>;
|
|
26
|
-
dispatch(input:
|
|
27
|
+
dispatch(input: CommandMatchInput, source?: unknown): Promise<CommandDispatchResult>;
|
|
27
28
|
}
|
|
28
29
|
export declare function isCommandIndex(value: unknown): value is CommandIndex;
|
|
29
30
|
export declare class CommandParameterValueError extends TypeError {
|
package/lib/command-index.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
+
import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
|
|
1
2
|
import { createCommandContext, } from './definition.js';
|
|
3
|
+
const segmentFields = {
|
|
4
|
+
text: 'text',
|
|
5
|
+
mention: 'target',
|
|
6
|
+
at: ['target', 'user_id', 'qq'],
|
|
7
|
+
face: 'id',
|
|
8
|
+
image: (segment) => segment.data.media ?? segment.data.file ?? segment.data.url ?? segment.data.src,
|
|
9
|
+
reply: ['message_id', 'reply_id', 'id'],
|
|
10
|
+
forward: ['forward_id', 'res_id', 'message_id', 'id'],
|
|
11
|
+
dice: 'result',
|
|
12
|
+
rps: 'result',
|
|
13
|
+
};
|
|
2
14
|
export class CommandIndex {
|
|
3
15
|
snapshot;
|
|
4
16
|
$projection = 'zhin.command-index/1';
|
|
5
17
|
#commands;
|
|
6
|
-
#staticCommands = new Map();
|
|
7
|
-
#dynamicCommands = new Map();
|
|
8
18
|
constructor(slots, snapshot) {
|
|
9
19
|
this.snapshot = snapshot;
|
|
10
20
|
const commands = [];
|
|
21
|
+
const staticCommands = new Map();
|
|
22
|
+
const dynamicCommands = new Map();
|
|
11
23
|
for (const slot of slots) {
|
|
12
24
|
const segments = runtimeSegments(slot.owner, slot.localName);
|
|
13
25
|
const parameter = slot.definition.$parameter;
|
|
@@ -24,29 +36,30 @@ export class CommandIndex {
|
|
|
24
36
|
slot,
|
|
25
37
|
segments: Object.freeze(segments),
|
|
26
38
|
parameter,
|
|
39
|
+
matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
|
|
27
40
|
});
|
|
28
41
|
if (!parameter) {
|
|
29
42
|
const key = segments.join(' ');
|
|
30
|
-
if (
|
|
43
|
+
if (staticCommands.has(key))
|
|
31
44
|
throw duplicateCommand(key);
|
|
32
|
-
|
|
45
|
+
staticCommands.set(key, record);
|
|
33
46
|
}
|
|
34
47
|
else {
|
|
35
48
|
const shape = routeShape(segments);
|
|
36
|
-
if (
|
|
49
|
+
if (dynamicCommands.has(shape))
|
|
37
50
|
throw duplicateCommand(name);
|
|
38
|
-
|
|
51
|
+
dynamicCommands.set(shape, record);
|
|
39
52
|
}
|
|
40
53
|
commands.push(record);
|
|
41
54
|
}
|
|
42
|
-
this.#commands = Object.freeze(commands);
|
|
55
|
+
this.#commands = Object.freeze(commands.sort(compareCommands));
|
|
43
56
|
}
|
|
44
57
|
list() {
|
|
45
58
|
return this.#commands.map(toDescriptor);
|
|
46
59
|
}
|
|
47
60
|
has(name) {
|
|
48
61
|
try {
|
|
49
|
-
return this.#match(name) !== undefined;
|
|
62
|
+
return this.#match(name, true) !== undefined;
|
|
50
63
|
}
|
|
51
64
|
catch (error) {
|
|
52
65
|
if (error instanceof CommandParameterValueError)
|
|
@@ -55,53 +68,65 @@ export class CommandIndex {
|
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
70
|
async execute(name, args = []) {
|
|
58
|
-
const match = this.#match(name);
|
|
59
|
-
if (!match)
|
|
71
|
+
const match = this.#match(name, true);
|
|
72
|
+
if (!match) {
|
|
73
|
+
this.#diagnoseParameter(name);
|
|
60
74
|
throw new Error(`Unknown Command: ${name}`);
|
|
75
|
+
}
|
|
61
76
|
return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params));
|
|
62
77
|
}
|
|
63
78
|
async dispatch(input, source = undefined) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
const match = this.#match(input, false);
|
|
80
|
+
if (!match)
|
|
81
|
+
return Object.freeze({ matched: false });
|
|
82
|
+
const args = textArgs(match.remaining);
|
|
83
|
+
const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source, match.remaining));
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
matched: true,
|
|
86
|
+
command: match.command.name,
|
|
87
|
+
owner: match.command.slot.owner,
|
|
88
|
+
value,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
#match(input, exact) {
|
|
92
|
+
const segments = normalizeSegments(typeof input === 'string'
|
|
93
|
+
? input.trim()
|
|
94
|
+
? [{ type: 'text', data: { text: input.trim() } }]
|
|
95
|
+
: []
|
|
96
|
+
: input);
|
|
97
|
+
if (segments.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
for (const command of this.#commands) {
|
|
100
|
+
const result = command.matcher.match(asMatcherSegments(segments));
|
|
101
|
+
if (!result || !hasCommandBoundary(result.remaining))
|
|
68
102
|
continue;
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
command
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
103
|
+
const remaining = normalizeSegments(result.remaining);
|
|
104
|
+
if (exact && remaining.length > 0)
|
|
105
|
+
continue;
|
|
106
|
+
return {
|
|
107
|
+
command,
|
|
108
|
+
params: Object.freeze({ ...result.params }),
|
|
109
|
+
remaining,
|
|
110
|
+
};
|
|
77
111
|
}
|
|
78
|
-
return
|
|
112
|
+
return undefined;
|
|
79
113
|
}
|
|
80
|
-
#
|
|
114
|
+
#diagnoseParameter(name) {
|
|
81
115
|
const words = splitCommand(name);
|
|
82
|
-
|
|
83
|
-
const staticCommand = this.#staticCommands.get(words.join(' '));
|
|
84
|
-
if (staticCommand)
|
|
85
|
-
return { command: staticCommand, params: Object.freeze({}) };
|
|
86
|
-
for (const command of this.#dynamicCommands.values()) {
|
|
116
|
+
for (const command of this.#commands) {
|
|
87
117
|
const parameter = command.parameter;
|
|
88
|
-
|
|
89
|
-
if (words.length !== command.segments.length &&
|
|
90
|
-
!(optional && words.length === command.segments.length - 1))
|
|
118
|
+
if (!parameter)
|
|
91
119
|
continue;
|
|
92
120
|
const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
|
|
121
|
+
if (words.length !== command.segments.length)
|
|
122
|
+
continue;
|
|
93
123
|
if (!command.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
|
|
94
124
|
continue;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
command,
|
|
101
|
-
params: Object.freeze({ [parameter.name]: value }),
|
|
102
|
-
};
|
|
125
|
+
const value = words[parameterIndex];
|
|
126
|
+
if (value === undefined || matchesParameter(parameter.type, value))
|
|
127
|
+
continue;
|
|
128
|
+
throw new CommandParameterValueError(parameter.name, parameter.type, value);
|
|
103
129
|
}
|
|
104
|
-
return undefined;
|
|
105
130
|
}
|
|
106
131
|
}
|
|
107
132
|
export function isCommandIndex(value) {
|
|
@@ -118,9 +143,9 @@ function assertParameterSegment(segments, parameter, source) {
|
|
|
118
143
|
const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
|
|
119
144
|
if (!parameter && dynamicSegments.length === 0)
|
|
120
145
|
return;
|
|
121
|
-
if (parameter && dynamicSegments.length === 1
|
|
122
|
-
dynamicSegments[0] === `$${parameter.name}`
|
|
123
|
-
segments.at(-1) === dynamicSegments[0])
|
|
146
|
+
if (parameter && dynamicSegments.length === 1
|
|
147
|
+
&& dynamicSegments[0] === `$${parameter.name}`
|
|
148
|
+
&& segments.at(-1) === dynamicSegments[0])
|
|
124
149
|
return;
|
|
125
150
|
throw new Error(`Broken dynamic Command identity for ${source}`);
|
|
126
151
|
}
|
|
@@ -133,27 +158,93 @@ function displayName(segments, parameter) {
|
|
|
133
158
|
: `[${segment.slice(1)}]`;
|
|
134
159
|
}).join(' ');
|
|
135
160
|
}
|
|
161
|
+
function matcherPattern(segments, parameter) {
|
|
162
|
+
return segments.map((segment) => {
|
|
163
|
+
if (!segment.startsWith('$'))
|
|
164
|
+
return segment;
|
|
165
|
+
if (!parameter)
|
|
166
|
+
throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
167
|
+
const type = matcherType(parameter.type);
|
|
168
|
+
return parameter.defaultValue === undefined
|
|
169
|
+
? `<${parameter.name}:${type}>`
|
|
170
|
+
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
171
|
+
}).join(' ');
|
|
172
|
+
}
|
|
173
|
+
function matcherType(type) {
|
|
174
|
+
return type === 'string' ? 'word' : type;
|
|
175
|
+
}
|
|
136
176
|
function routeShape(segments) {
|
|
137
177
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
138
178
|
}
|
|
179
|
+
function compareCommands(left, right) {
|
|
180
|
+
const leftDynamic = left.parameter ? 1 : 0;
|
|
181
|
+
const rightDynamic = right.parameter ? 1 : 0;
|
|
182
|
+
return leftDynamic - rightDynamic
|
|
183
|
+
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
184
|
+
|| right.segments.length - left.segments.length
|
|
185
|
+
|| right.name.length - left.name.length
|
|
186
|
+
|| left.name.localeCompare(right.name);
|
|
187
|
+
}
|
|
188
|
+
function staticSegmentCount(segments) {
|
|
189
|
+
return segments.filter((segment) => !segment.startsWith('$')).length;
|
|
190
|
+
}
|
|
139
191
|
function splitCommand(value) {
|
|
140
192
|
const normalized = value.trim();
|
|
141
193
|
return normalized ? normalized.split(/\s+/) : [];
|
|
142
194
|
}
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
195
|
+
function matchesParameter(type, value) {
|
|
196
|
+
const matcher = TypeMatcherRegistry.getMatcher(matcherType(type));
|
|
197
|
+
return matcher ? matcher.match(value).success : false;
|
|
198
|
+
}
|
|
199
|
+
function asMatcherSegments(segments) {
|
|
200
|
+
return segments.map((segment) => ({
|
|
201
|
+
type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
|
|
202
|
+
data: { ...segment.data },
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
function hasCommandBoundary(segments) {
|
|
206
|
+
const first = segments[0];
|
|
207
|
+
if (!first || first.type !== 'text')
|
|
208
|
+
return true;
|
|
209
|
+
const text = first.data.text;
|
|
210
|
+
return typeof text !== 'string' || text.length === 0 || /^\s/u.test(text);
|
|
211
|
+
}
|
|
212
|
+
function normalizeSegments(input) {
|
|
213
|
+
const segments = input.map((segment) => ({
|
|
214
|
+
type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
|
|
215
|
+
data: { ...segment.data },
|
|
216
|
+
}));
|
|
217
|
+
trimBoundary(segments, 'start');
|
|
218
|
+
trimBoundary(segments, 'end');
|
|
219
|
+
return Object.freeze(segments.map((segment) => Object.freeze({
|
|
220
|
+
type: typeof segment.type === 'string'
|
|
221
|
+
? segment.type
|
|
222
|
+
: Object.freeze({ name: segment.type.name }),
|
|
223
|
+
data: Object.freeze(segment.data),
|
|
224
|
+
})));
|
|
225
|
+
}
|
|
226
|
+
function trimBoundary(segments, side) {
|
|
227
|
+
while (segments.length > 0) {
|
|
228
|
+
const index = side === 'start' ? 0 : segments.length - 1;
|
|
229
|
+
const segment = segments[index];
|
|
230
|
+
if (!segment || segment.type !== 'text' || typeof segment.data.text !== 'string')
|
|
231
|
+
return;
|
|
232
|
+
const text = side === 'start' ? segment.data.text.trimStart() : segment.data.text.trimEnd();
|
|
233
|
+
if (text) {
|
|
234
|
+
segment.data.text = text;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
segments.splice(index, 1);
|
|
153
238
|
}
|
|
154
|
-
throw new CommandParameterValueError(parameter.name, parameter.type, value);
|
|
155
239
|
}
|
|
156
|
-
function
|
|
240
|
+
function textArgs(segments) {
|
|
241
|
+
return Object.freeze(segments.flatMap((segment) => {
|
|
242
|
+
if (segment.type !== 'text' || typeof segment.data.text !== 'string')
|
|
243
|
+
return [];
|
|
244
|
+
return splitCommand(segment.data.text);
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, matcher: _matcher, ...descriptor }) {
|
|
157
248
|
return descriptor;
|
|
158
249
|
}
|
|
159
250
|
function duplicateCommand(name) {
|
package/lib/definition.d.ts
CHANGED
|
@@ -1,26 +1,105 @@
|
|
|
1
1
|
import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import { type CapabilityContext } from '@zhin.js/feature-kit';
|
|
3
3
|
declare const commandBrand: "zhin.command/1";
|
|
4
|
-
export type CommandParameterType = 'string' | 'number' | 'boolean';
|
|
5
|
-
export type CommandParameterValue = string | number | boolean;
|
|
4
|
+
export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
|
|
5
|
+
export type CommandParameterValue = string | number | boolean | Readonly<Record<string, unknown>> | null;
|
|
6
|
+
/** Minimal structural contract shared with canonical IM segments. */
|
|
7
|
+
export interface CommandSegment {
|
|
8
|
+
readonly type: string | {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
};
|
|
11
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
6
13
|
export interface CommandParameterDefinition {
|
|
7
14
|
readonly name: string;
|
|
8
15
|
readonly type: CommandParameterType;
|
|
9
16
|
readonly defaultValue?: CommandParameterValue;
|
|
10
17
|
}
|
|
11
|
-
|
|
18
|
+
/** 场景:群 / 私聊 / 频道等。 */
|
|
19
|
+
export interface CommandScene {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly type: string;
|
|
22
|
+
readonly name?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 发送者。
|
|
26
|
+
* `role` 为角色列表(如 `user` / `trusted` / `master`,以及平台侧 `owner` / `admin` 等)。
|
|
27
|
+
*/
|
|
28
|
+
export interface CommandSender {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
readonly role: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 命令侧入站消息契约。
|
|
35
|
+
*
|
|
36
|
+
* `@zhin.js/core/runtime` 的 `Message` 结构兼容本接口(duck typing)。
|
|
37
|
+
* 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
|
|
38
|
+
*/
|
|
39
|
+
export interface CommandMessage {
|
|
40
|
+
readonly adapter: string;
|
|
41
|
+
readonly target: string;
|
|
42
|
+
readonly content: string;
|
|
43
|
+
/** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
|
|
44
|
+
readonly sender?: string;
|
|
45
|
+
readonly id?: string;
|
|
46
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
47
|
+
/** 若上游已结构化,优先采用。 */
|
|
48
|
+
readonly scene?: CommandScene;
|
|
49
|
+
readonly $reply?: (content: unknown) => Promise<unknown>;
|
|
50
|
+
readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* IM 入站快捷字段。
|
|
54
|
+
* 有 `CommandMessage` 来源时由 {@link resolveCommandSession} 填充;
|
|
55
|
+
* `CommandIndex.execute(name)` 等无消息路径下为 `undefined`。
|
|
56
|
+
*/
|
|
57
|
+
export interface CommandSession {
|
|
58
|
+
/**
|
|
59
|
+
* 适配器插件实例 id(CapabilityId 的 owner 段,如 `root/icqq`)。
|
|
60
|
+
* 与 `snapshot.config.get(adapter)` 对齐。
|
|
61
|
+
*/
|
|
62
|
+
readonly adapter?: string;
|
|
63
|
+
/** Endpoint 名(`metadata.endpoint`)。 */
|
|
64
|
+
readonly endpoint?: string;
|
|
65
|
+
/** 场景对象(id / type / name)。 */
|
|
66
|
+
readonly scene?: CommandScene;
|
|
67
|
+
/** 发送者对象(id / name / role[])。 */
|
|
68
|
+
readonly sender?: CommandSender;
|
|
69
|
+
}
|
|
70
|
+
export interface CommandContext<TConfig = unknown, TInput extends CommandMessage = CommandMessage> extends CapabilityContext<TConfig>, CommandSession {
|
|
12
71
|
readonly args: readonly string[];
|
|
13
72
|
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
14
|
-
|
|
73
|
+
/** Structured arguments left after the command pattern was consumed. */
|
|
74
|
+
readonly segments: readonly Readonly<CommandSegment>[];
|
|
75
|
+
/**
|
|
76
|
+
* 派发来源。IM 命中时为 Runtime `Message`(满足 {@link CommandMessage});
|
|
77
|
+
* Host / `CommandIndex.execute` 等无消息路径可能为 `undefined`。
|
|
78
|
+
*/
|
|
79
|
+
readonly input?: TInput;
|
|
15
80
|
}
|
|
16
|
-
export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput =
|
|
81
|
+
export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage> {
|
|
17
82
|
readonly $feature: typeof commandBrand;
|
|
18
83
|
readonly $parameter?: CommandParameterDefinition;
|
|
19
84
|
readonly description?: string;
|
|
20
85
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
21
86
|
}
|
|
22
|
-
|
|
23
|
-
|
|
87
|
+
declare module '@zhin.js/plugin-runtime' {
|
|
88
|
+
interface PluginSetupContext<TConfig> {
|
|
89
|
+
addCommand<TResult = unknown, TInput extends CommandMessage = CommandMessage>(localName: string, definition: CommandDefinition<TConfig, TResult, TInput>): void;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 定义一个命令模块(`commands/` 约定目录下默认导出)。
|
|
94
|
+
* @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
|
|
95
|
+
*/
|
|
96
|
+
export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage>(definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
|
|
97
|
+
export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage>(definition: CommandDefinition<TConfig, TResult, TInput>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
|
|
24
98
|
export declare function parseCommandDefinition(value: unknown): CommandDefinition;
|
|
25
|
-
export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown): CommandContext;
|
|
99
|
+
export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[]): CommandContext;
|
|
100
|
+
/**
|
|
101
|
+
* 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
|
|
102
|
+
* 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveCommandSession(input: unknown): CommandSession;
|
|
26
105
|
export {};
|
package/lib/definition.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createCapabilityContext, } from '@zhin.js/feature-kit';
|
|
2
2
|
const commandBrand = 'zhin.command/1';
|
|
3
|
+
/**
|
|
4
|
+
* 定义一个命令模块(`commands/` 约定目录下默认导出)。
|
|
5
|
+
* @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
|
|
6
|
+
*/
|
|
3
7
|
export function defineCommand(definition) {
|
|
4
8
|
if (typeof definition.execute !== 'function') {
|
|
5
9
|
throw new TypeError('Command execute must be a function');
|
|
@@ -21,12 +25,167 @@ export function parseCommandDefinition(value) {
|
|
|
21
25
|
}
|
|
22
26
|
return definition;
|
|
23
27
|
}
|
|
24
|
-
export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined) {
|
|
28
|
+
export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([])) {
|
|
25
29
|
const context = createCapabilityContext(snapshot, ownerId);
|
|
30
|
+
const session = resolveCommandSession(input);
|
|
26
31
|
return Object.freeze({
|
|
27
32
|
...context,
|
|
33
|
+
...session,
|
|
28
34
|
args: Object.freeze([...args]),
|
|
29
35
|
params: Object.freeze({ ...params }),
|
|
30
|
-
|
|
36
|
+
segments: freezeSegments(segments),
|
|
37
|
+
...(input !== undefined ? { input: input } : {}),
|
|
31
38
|
});
|
|
32
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
|
|
42
|
+
* 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCommandSession(input) {
|
|
45
|
+
if (!isCommandMessageLike(input))
|
|
46
|
+
return Object.freeze({});
|
|
47
|
+
const metadata = input.metadata && typeof input.metadata === 'object'
|
|
48
|
+
? input.metadata
|
|
49
|
+
: undefined;
|
|
50
|
+
const adapter = input.adapter.split('\0')[0] || undefined;
|
|
51
|
+
const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
|
|
52
|
+
? metadata.endpoint
|
|
53
|
+
: undefined;
|
|
54
|
+
const scene = resolveScene(input, metadata);
|
|
55
|
+
const sender = resolveSender(input, metadata);
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
...(adapter ? { adapter } : {}),
|
|
58
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
59
|
+
...(scene !== undefined ? { scene } : {}),
|
|
60
|
+
...(sender !== undefined ? { sender } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function isCommandMessageLike(input) {
|
|
64
|
+
if (!input || typeof input !== 'object')
|
|
65
|
+
return false;
|
|
66
|
+
const value = input;
|
|
67
|
+
return typeof value.adapter === 'string'
|
|
68
|
+
&& typeof value.target === 'string'
|
|
69
|
+
&& typeof value.content === 'string';
|
|
70
|
+
}
|
|
71
|
+
function resolveScene(input, metadata) {
|
|
72
|
+
if (isCommandScene(input.scene)) {
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
id: input.scene.id,
|
|
75
|
+
type: input.scene.type,
|
|
76
|
+
...(input.scene.name !== undefined ? { name: input.scene.name } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const parsed = parseTarget(input.target);
|
|
80
|
+
const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
|
|
81
|
+
|| (typeof metadata?.type === 'string' && metadata.type)
|
|
82
|
+
|| parsed?.type;
|
|
83
|
+
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
84
|
+
|| parsed?.id;
|
|
85
|
+
if (!type || !id)
|
|
86
|
+
return undefined;
|
|
87
|
+
const name = firstString(metadata?.channelName, metadata?.group_name, metadata?.groupName, metadata?.sceneName);
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
id,
|
|
90
|
+
type,
|
|
91
|
+
...(name !== undefined ? { name } : {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function resolveSender(input, metadata) {
|
|
95
|
+
const structured = input.from;
|
|
96
|
+
if (isCommandSender(structured)) {
|
|
97
|
+
return freezeSender(structured);
|
|
98
|
+
}
|
|
99
|
+
// 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
|
|
100
|
+
if (isCommandSender(input.sender)) {
|
|
101
|
+
return freezeSender(input.sender);
|
|
102
|
+
}
|
|
103
|
+
const id = typeof input.sender === 'string' && input.sender
|
|
104
|
+
? input.sender
|
|
105
|
+
: firstString(metadata?.user_id, metadata?.userId);
|
|
106
|
+
if (!id)
|
|
107
|
+
return undefined;
|
|
108
|
+
const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
109
|
+
const role = resolveRoles(metadata);
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
id,
|
|
112
|
+
...(name !== undefined ? { name } : {}),
|
|
113
|
+
role,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function resolveRoles(metadata) {
|
|
117
|
+
const roles = [];
|
|
118
|
+
const push = (value) => {
|
|
119
|
+
if (typeof value !== 'string')
|
|
120
|
+
return;
|
|
121
|
+
const trimmed = value.trim();
|
|
122
|
+
if (trimmed && !roles.includes(trimmed))
|
|
123
|
+
roles.push(trimmed);
|
|
124
|
+
};
|
|
125
|
+
if (Array.isArray(metadata?.roles)) {
|
|
126
|
+
for (const item of metadata.roles)
|
|
127
|
+
push(item);
|
|
128
|
+
}
|
|
129
|
+
push(metadata?.senderRole);
|
|
130
|
+
push(metadata?.role);
|
|
131
|
+
if (metadata?.isMaster === true)
|
|
132
|
+
push('master');
|
|
133
|
+
if (metadata?.isTrusted === true)
|
|
134
|
+
push('trusted');
|
|
135
|
+
if (roles.length === 0)
|
|
136
|
+
roles.push('user');
|
|
137
|
+
return Object.freeze(roles);
|
|
138
|
+
}
|
|
139
|
+
function parseTarget(target) {
|
|
140
|
+
const parts = target.split(':').filter(Boolean);
|
|
141
|
+
if (parts.length < 2)
|
|
142
|
+
return undefined;
|
|
143
|
+
const kind = parts[0];
|
|
144
|
+
if (kind === 'channel' && parts.length >= 3) {
|
|
145
|
+
return { type: 'channel', id: parts[parts.length - 1] };
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'temp' && parts.length >= 3) {
|
|
148
|
+
return { type: 'private', id: parts[parts.length - 1] };
|
|
149
|
+
}
|
|
150
|
+
return { type: kind, id: parts.slice(1).join(':') };
|
|
151
|
+
}
|
|
152
|
+
function isCommandScene(value) {
|
|
153
|
+
if (!value || typeof value !== 'object')
|
|
154
|
+
return false;
|
|
155
|
+
const scene = value;
|
|
156
|
+
return typeof scene.id === 'string'
|
|
157
|
+
&& scene.id.length > 0
|
|
158
|
+
&& typeof scene.type === 'string'
|
|
159
|
+
&& scene.type.length > 0;
|
|
160
|
+
}
|
|
161
|
+
function isCommandSender(value) {
|
|
162
|
+
if (!value || typeof value !== 'object')
|
|
163
|
+
return false;
|
|
164
|
+
const sender = value;
|
|
165
|
+
return typeof sender.id === 'string'
|
|
166
|
+
&& sender.id.length > 0
|
|
167
|
+
&& Array.isArray(sender.role)
|
|
168
|
+
&& sender.role.every((item) => typeof item === 'string');
|
|
169
|
+
}
|
|
170
|
+
function freezeSender(sender) {
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
id: sender.id,
|
|
173
|
+
...(sender.name !== undefined ? { name: sender.name } : {}),
|
|
174
|
+
role: Object.freeze([...sender.role]),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function firstString(...values) {
|
|
178
|
+
for (const value of values) {
|
|
179
|
+
if (typeof value === 'string' && value.trim())
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function freezeSegments(segments) {
|
|
185
|
+
return Object.freeze(segments.map((segment) => Object.freeze({
|
|
186
|
+
type: typeof segment.type === 'string'
|
|
187
|
+
? segment.type
|
|
188
|
+
: Object.freeze({ name: segment.type.name }),
|
|
189
|
+
data: Object.freeze({ ...segment.data }),
|
|
190
|
+
})));
|
|
191
|
+
}
|