@jsbot-tellus/core 2.9.1 → 2.10.0

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 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../../src/core/plugin/params/types.ts"],"names":[],"mappings":"AAsBA,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAChC,KAAK,CAAQ;IAEtB,YAAY,OAAe,EAAE,KAAa;QACtC,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAA;QAChC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;IACtB,CAAC;CACJ;AAsBD,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC5C,OAAO,IAAI,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;AACzC,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../../src/core/plugin/params/types.ts"],"names":[],"mappings":"AAsBA,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAChC,KAAK,CAAQ;IAEtB,YAAY,OAAe,EAAE,KAAa;QACtC,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAA;QAChC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;IACtB,CAAC;CACJ;AAkCD,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC5C,OAAO,IAAI,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;AACzC,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,SAAyB;IACpD,IAAI,SAAS,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,SAAS,CAAC,KAAK;aAC1B,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC;aACtB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;QAEzB,OAAO,IAAI,OAAO,GAAG,CAAA;IACzB,CAAC;IAED,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAA;IAE/B,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACvB,OAAO,SAAS,CAAC,IAAI,CAAA;IACzB,CAAC;IAED,OAAO,GAAG,SAAS,CAAC,IAAI,IAAI,MAAM,CAAC,GAAG,IAAI,EAAE,KAAK,MAAM,CAAC,GAAG,IAAI,EAAE,GAAG,CAAA;AACxE,CAAC;AAED,MAAM,UAAU,mBAAmB,CAC/B,UAAqC;IAErC,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;IAE7C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IAElC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IAE/C,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;IAE5C,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAA;AACvD,CAAC"}
@@ -250,6 +250,8 @@ ctx.commands.register(
250
250
  | `<arg:type>` | 指定类型的必填参数 |
251
251
  | `[arg:type=default]` | 可选参数及默认值 |
252
252
  | `<arg:string(2..10)>` | 带长度限制的字符串参数 |
253
+ | `<arg:type1\|type2>` | 联合类型,按声明顺序尝试 |
254
+ | `<arg:'a'\|'b'>` | 字面量联合,输入必须匹配其中一个值 |
253
255
  | `<arg:text>` | 文本参数,消费全部剩余输入,必须放在最后 |
254
256
  | `[...rest]` | 变长参数,每个剩余 token 作为一个字符串 |
255
257
 
@@ -277,20 +279,38 @@ params: '<message:text(2..200)> [nickname:string(..16)]'
277
279
  - 只能用于 `string` / `text`;其他类型和 `[...rest]` 会报声明错误。
278
280
  - 默认值也会在注册时校验;usage 不会显示长度限制。
279
281
 
282
+ 联合类型:
283
+
284
+ `|` 可以把多个内置类型或字面量组合成一个参数,解析时按声明顺序尝试,第一个成功的候选生效:
285
+
286
+ ```ts
287
+ params: "<value:string|integer> [mode:'fast'|'slow'='fast']"
288
+ ```
289
+
290
+ - 内置类型联合:`<value:string|integer>`,handler 推导为 `string | number`。
291
+ - 字面量联合:`<mode:'fast'|'slow'>`,输入必须精确等于其中一个值,区分大小写;handler 推导为 `'fast' | 'slow'`。字面量必须用单引号或双引号包裹,支持空格、`|`、`=`、`:` 和转义引号。
292
+ - 顺序很重要:`<value:string|integer>` 会把 `123` 解析为字符串 `'123'`;需要数字时写成 `<value:integer|string>`。
293
+ - `string` 候选可以带长度限制,例如 `<name:string(2..10)|integer>`。
294
+ - `text` 和 `reply` 不能用于联合类型;`[...rest]` 仍然只收集字符串。
295
+ - 默认值也按顺序尝试,例如 `[mode:'fast'|'slow'='fast']`。`at` 候选不支持默认值,会被跳过;如果所有候选都无法解析默认值,注册时抛错。
296
+ - 重复的候选会被去重。
297
+ - 可选字面量联合 `[mode:'fast'|'slow']` 推导为 `'fast' | 'slow' | undefined`;`CommandParamArgs<"<mode:'fast'|'slow'>">` 推导为 `['fast' | 'slow']`。
298
+
280
299
  固定参数可以用双引号或单引号包裹,例如 `/ask "hello world" 2` 会把 `hello world` 作为一个 token;引号内的反斜杠用于转义。
281
300
 
282
301
  解析规则:
283
302
 
284
303
  - `match.args` 和 `match.rest` 保持原始内容,不会被参数解析结果替换。
285
304
  - 声明槽位填完后,多余 token 会被忽略并记录 debug 日志,不会报错。`text` 和 `rest` 会消费全部剩余输入,因此不存在多余 token。
286
- - 必填参数缺失、已声明槽位类型错误、长度不符合限制或引号未闭合时,Router 会自动回复错误和用法,handler 不会执行。
305
+ - 必填参数缺失、已声明槽位类型错误、联合类型的所有候选都不匹配、长度不符合限制或引号未闭合时,Router 会自动回复错误和用法,handler 不会执行。
287
306
  - usage 会自动生成,并显示在 `/help` 和指令组根命令的用法提示中。
288
307
  - 必填参数不能出现在可选参数之后;参数名不能重复;`text` 和变长参数必须放在最后;必填参数不能声明默认值;长度限制只能用于 `string` / `text`,上下限必须是非负整数且下限不能大于上限。
289
- - 当 `params` 是字符串字面量时,handler 从第四个参数开始的位置参数类型会自动推导。例如 `<question:string> [count:integer=1]` 会推导为 `(question: string, count: number)`,`[name:string]` 会推导为 `string | undefined`,`[...rest]` 会推导为 `...string[]`。
308
+ - 联合类型的候选不能为空;`text` 和 `reply` 不能参与联合类型;字面量必须使用单引号或双引号包裹。
309
+ - 当 `params` 是字符串字面量时,handler 从第四个参数开始的位置参数类型会自动推导。例如 `<question:string> [count:integer=1]` 会推导为 `(question: string, count: number)`,`[name:string]` 会推导为 `string | undefined`,`[...rest]` 会推导为 `...string[]`。联合类型会推导为 TypeScript 联合,例如 `<value:string|integer>` 推导为 `string | number`,`<mode:'fast'|'slow'>` 推导为 `'fast' | 'slow'`。
290
310
  - 需要在类型位置复用推导结果时,可以使用导出的 `CommandParamArgs<'<question:string>'>`。
291
311
  - 也可以继续手写 handler 参数注解;注解与声明不一致时会在编译期报错。
292
312
  - 自动推导只对字面量生效:运行时拼接的字符串,或显式声明为 `string` 的变量会回退为 `any[]`。需要保留字面量类型时,使用 `const` 变量或 `as const`。
293
- - 类型层解析覆盖上面的声明语法;如果默认值包含 `]` 等会干扰声明边界的字符,可能回退为 `any[]`,但不影响运行时解析。非法声明仍然在注册时抛错,而不是变成类型错误。
313
+ - 类型层解析覆盖上面的声明语法,包括联合类型和引号字面量;如果字面量或默认值包含 `>`、`]` 等会干扰声明边界的字符,可能回退为 `any[]`,但不影响运行时解析。非法声明仍然在注册时抛错,而不是变成类型错误。
294
314
 
295
315
  参数错误示例:
296
316
 
@@ -298,6 +318,10 @@ params: '<message:text(2..200)> [nickname:string(..16)]'
298
318
  /ask hello abc
299
319
  -> 参数 count 需要是整数
300
320
  -> 用法:/ask <question> [count]
321
+
322
+ /mode medium
323
+ -> 参数 mode 需要是 'fast' 或 'slow'
324
+ -> 用法:/mode <mode>
301
325
  ```
302
326
 
303
327
  ### 指令组
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsbot-tellus/core",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "engines": {
5
5
  "node": "^24.22.1 || >=26.12.1"
6
6
  },
@@ -1,8 +1,12 @@
1
1
  import {
2
+ candidateLabel,
2
3
  declarationError,
4
+ joinCandidateLabels,
3
5
  type CommandParamType,
6
+ type ParamCandidate,
4
7
  type ParamLength,
5
8
  type ParamSpec,
9
+ type ParamTypeCandidate,
6
10
  } from './types.js'
7
11
  import {
8
12
  measureLength,
@@ -30,11 +34,6 @@ export function parseType(value: string): CommandParamType {
30
34
  }
31
35
  }
32
36
 
33
- interface ParsedType {
34
- type: CommandParamType
35
- length?: ParamLength | undefined
36
- }
37
-
38
37
  function parseLengthNumber(raw: string, token: string): number {
39
38
  const value = Number(raw)
40
39
 
@@ -70,12 +69,34 @@ function parseLength(raw: string, token: string): ParamLength {
70
69
  return { min, max }
71
70
  }
72
71
 
73
- function parseTypeDeclaration(raw: string, token: string): ParsedType {
74
- const value = raw.trim()
72
+ function isQuoted(value: string): boolean {
73
+ const quote = value[0]
74
+
75
+ return quote === '"' || quote === "'"
76
+ }
77
+
78
+ function parseLiteral(value: string, token: string): string {
79
+ const quote = value[0]
80
+
81
+ if (
82
+ (quote !== '"' && quote !== "'") ||
83
+ value.length < 2 ||
84
+ value[value.length - 1] !== quote
85
+ ) {
86
+ throw declarationError(`非法的字面量 "${value}":${token}`)
87
+ }
88
+
89
+ return unquote(value)
90
+ }
91
+
92
+ function parseBuiltinCandidate(
93
+ value: string,
94
+ token: string
95
+ ): ParamTypeCandidate {
75
96
  const open = value.indexOf('(')
76
97
 
77
98
  if (open < 0) {
78
- return { type: parseType(value) }
99
+ return { kind: 'type', type: parseType(value) }
79
100
  }
80
101
 
81
102
  if (!value.endsWith(')')) {
@@ -89,7 +110,112 @@ function parseTypeDeclaration(raw: string, token: string): ParsedType {
89
110
  throw declarationError(`只有 string/text 参数支持长度限制:${token}`)
90
111
  }
91
112
 
92
- return { type, length }
113
+ return { kind: 'type', type, length }
114
+ }
115
+
116
+ function candidateKey(candidate: ParamCandidate): string {
117
+ if (candidate.kind === 'literal') {
118
+ return `literal:${candidate.value}`
119
+ }
120
+
121
+ const length = candidate.length
122
+ const range =
123
+ length === undefined ? '' : `${length.min ?? ''}..${length.max ?? ''}`
124
+
125
+ return `type:${candidate.type}:${range}`
126
+ }
127
+
128
+ function splitUnion(raw: string, token: string): string[] {
129
+ const parts: string[] = []
130
+ let current = ''
131
+ let quote: string | null = null
132
+
133
+ for (let index = 0; index < raw.length; index += 1) {
134
+ const char = raw[index]
135
+
136
+ if (char === undefined) break
137
+
138
+ if (quote !== null) {
139
+ current += char
140
+
141
+ if (char === '\\') {
142
+ const next = raw[index + 1]
143
+
144
+ if (next !== undefined) {
145
+ current += next
146
+ index += 1
147
+ }
148
+
149
+ continue
150
+ }
151
+
152
+ if (char === quote) {
153
+ quote = null
154
+ }
155
+
156
+ continue
157
+ }
158
+
159
+ if (char === '"' || char === "'") {
160
+ quote = char
161
+ current += char
162
+ continue
163
+ }
164
+
165
+ if (char === '|') {
166
+ parts.push(current)
167
+ current = ''
168
+ continue
169
+ }
170
+
171
+ current += char
172
+ }
173
+
174
+ if (quote !== null) {
175
+ throw declarationError(`参数 "${token}" 的引号未闭合`)
176
+ }
177
+
178
+ parts.push(current)
179
+
180
+ return parts
181
+ }
182
+
183
+ function parseCandidates(raw: string, token: string): ParamCandidate[] {
184
+ const candidates: ParamCandidate[] = []
185
+ const seen = new Set<string>()
186
+
187
+ for (const part of splitUnion(raw, token)) {
188
+ const value = part.trim()
189
+
190
+ if (!value) {
191
+ throw declarationError(`联合类型中存在空的类型:${token}`)
192
+ }
193
+
194
+ const candidate = isQuoted(value)
195
+ ? { kind: 'literal' as const, value: parseLiteral(value, token) }
196
+ : parseBuiltinCandidate(value, token)
197
+ const key = candidateKey(candidate)
198
+
199
+ if (seen.has(key)) continue
200
+
201
+ seen.add(key)
202
+ candidates.push(candidate)
203
+ }
204
+
205
+ if (candidates.length > 1) {
206
+ for (const candidate of candidates) {
207
+ if (
208
+ candidate.kind === 'type' &&
209
+ (candidate.type === 'text' || candidate.type === 'reply')
210
+ ) {
211
+ throw declarationError(
212
+ `${candidate.type} 不能用于联合类型:${token}`
213
+ )
214
+ }
215
+ }
216
+ }
217
+
218
+ return candidates
93
219
  }
94
220
 
95
221
  function validateDefaultLength(
@@ -187,6 +313,84 @@ export function splitDeclaration(input: string): string[] {
187
313
  return parts
188
314
  }
189
315
 
316
+ function hasUnquotedEllipsis(value: string): boolean {
317
+ let quote: string | null = null
318
+
319
+ for (let index = 0; index < value.length; index += 1) {
320
+ const char = value[index]
321
+
322
+ if (char === undefined) break
323
+
324
+ if (quote !== null) {
325
+ if (char === '\\') {
326
+ index += 1
327
+ continue
328
+ }
329
+
330
+ if (char === quote) {
331
+ quote = null
332
+ }
333
+
334
+ continue
335
+ }
336
+
337
+ if (char === '"' || char === "'") {
338
+ quote = char
339
+ continue
340
+ }
341
+
342
+ if (value.startsWith('...', index)) {
343
+ return true
344
+ }
345
+ }
346
+
347
+ return false
348
+ }
349
+
350
+ function splitDefault(
351
+ inner: string,
352
+ token: string
353
+ ): { head: string; defaultRaw?: string | undefined } {
354
+ let quote: string | null = null
355
+
356
+ for (let index = 0; index < inner.length; index += 1) {
357
+ const char = inner[index]
358
+
359
+ if (char === undefined) break
360
+
361
+ if (quote !== null) {
362
+ if (char === '\\') {
363
+ index += 1
364
+ continue
365
+ }
366
+
367
+ if (char === quote) {
368
+ quote = null
369
+ }
370
+
371
+ continue
372
+ }
373
+
374
+ if (char === '"' || char === "'") {
375
+ quote = char
376
+ continue
377
+ }
378
+
379
+ if (char === '=') {
380
+ return {
381
+ head: inner.slice(0, index).trim(),
382
+ defaultRaw: inner.slice(index + 1).trim(),
383
+ }
384
+ }
385
+ }
386
+
387
+ if (quote !== null) {
388
+ throw declarationError(`参数 "${token}" 的引号未闭合`)
389
+ }
390
+
391
+ return { head: inner.trim() }
392
+ }
393
+
190
394
  export function parseToken(token: string): ParamSpec {
191
395
  const open = token[0]
192
396
  const close = token[token.length - 1]
@@ -221,23 +425,22 @@ export function parseToken(token: string): ParamSpec {
221
425
 
222
426
  return {
223
427
  name,
224
- type: 'string',
428
+ candidates: [{ kind: 'type', type: 'string' }],
225
429
  required: false,
226
430
  rest: true,
227
431
  hasDefault: false,
228
432
  }
229
433
  }
230
434
 
231
- if (inner.includes('...')) {
435
+ const { head, defaultRaw } = splitDefault(inner, token)
436
+
437
+ if (hasUnquotedEllipsis(head)) {
232
438
  throw declarationError(`非法的参数声明 "${token}"`)
233
439
  }
234
440
 
235
- const equals = inner.indexOf('=')
236
- const head = equals >= 0 ? inner.slice(0, equals).trim() : inner.trim()
237
- const defaultRaw = equals >= 0 ? inner.slice(equals + 1).trim() : undefined
238
441
  const colon = head.indexOf(':')
239
442
  const name = validateName(colon >= 0 ? head.slice(0, colon) : head)
240
- const { type, length } = parseTypeDeclaration(
443
+ const candidates = parseCandidates(
241
444
  colon >= 0 ? head.slice(colon + 1) : 'string',
242
445
  token
243
446
  )
@@ -247,33 +450,33 @@ export function parseToken(token: string): ParamSpec {
247
450
  }
248
451
 
249
452
  if (defaultRaw !== undefined) {
250
- const defaultValue = parseDefaultValue(type, defaultRaw, name)
251
-
252
- if (typeof defaultValue === 'string') {
253
- validateDefaultLength(name, defaultValue, length)
254
- }
453
+ const defaultValue = parseDefaultValue(candidates, defaultRaw, name)
255
454
 
256
455
  return {
257
456
  name,
258
- type,
457
+ candidates,
259
458
  required,
260
459
  rest: false,
261
460
  hasDefault: true,
262
461
  defaultValue,
263
- length,
264
462
  }
265
463
  }
266
464
 
267
465
  return {
268
466
  name,
269
- type,
467
+ candidates,
270
468
  required,
271
469
  rest: false,
272
470
  hasDefault: false,
273
- length,
274
471
  }
275
472
  }
276
473
 
474
+ function hasTextCandidate(spec: ParamSpec): boolean {
475
+ return spec.candidates.some(
476
+ (candidate) => candidate.kind === 'type' && candidate.type === 'text'
477
+ )
478
+ }
479
+
277
480
  export function validateSpecs(specs: ParamSpec[]): void {
278
481
  const names = new Set<string>()
279
482
  let seenOptional = false
@@ -308,7 +511,7 @@ export function validateSpecs(specs: ParamSpec[]): void {
308
511
  continue
309
512
  }
310
513
 
311
- if (spec.type === 'text') {
514
+ if (hasTextCandidate(spec)) {
312
515
  if (index !== specs.length - 1) {
313
516
  throw declarationError('文本参数必须放在最后')
314
517
  }
@@ -333,23 +536,32 @@ export function usageOf(specs: ParamSpec[]): string {
333
536
  return `[...${spec.name}]`
334
537
  }
335
538
 
336
- const name = spec.type === 'text' ? `${spec.name}...` : spec.name
539
+ const name = hasTextCandidate(spec) ? `${spec.name}...` : spec.name
337
540
 
338
541
  return spec.required ? `<${name}>` : `[${name}]`
339
542
  })
340
543
  .join(' ')
341
544
  }
342
545
 
343
- export function parseDefaultValue(
344
- type: CommandParamType,
345
- raw: string,
546
+ function parseDefaultCandidate(
547
+ candidate: ParamCandidate,
548
+ value: string,
346
549
  name: string
347
550
  ): unknown {
348
- const value = unquote(raw)
551
+ if (candidate.kind === 'literal') {
552
+ if (value === candidate.value) {
553
+ return value
554
+ }
555
+
556
+ throw declarationError(
557
+ `参数 ${name} 的默认值需要是 ${candidateLabel(candidate)}`
558
+ )
559
+ }
349
560
 
350
- switch (type) {
561
+ switch (candidate.type) {
351
562
  case 'string':
352
563
  case 'text':
564
+ validateDefaultLength(name, value, candidate.length)
353
565
  return value
354
566
  case 'number': {
355
567
  const number = value.trim() === '' ? Number.NaN : Number(value)
@@ -392,3 +604,29 @@ export function parseDefaultValue(
392
604
  }
393
605
  }
394
606
  }
607
+
608
+ export function parseDefaultValue(
609
+ candidates: ParamCandidate[],
610
+ raw: string,
611
+ name: string
612
+ ): unknown {
613
+ const value = unquote(raw)
614
+
615
+ if (candidates.length === 1) {
616
+ return parseDefaultCandidate(candidates[0]!, value, name)
617
+ }
618
+
619
+ for (const candidate of candidates) {
620
+ try {
621
+ return parseDefaultCandidate(candidate, value, name)
622
+ } catch (error) {
623
+ if (!(error instanceof Error)) {
624
+ throw error
625
+ }
626
+ }
627
+ }
628
+
629
+ throw declarationError(
630
+ `参数 ${name} 的默认值需要是 ${joinCandidateLabels(candidates)}`
631
+ )
632
+ }
@@ -1,6 +1,10 @@
1
1
  import {
2
+ candidateLabel,
2
3
  CommandParamsError,
4
+ joinCandidateLabels,
3
5
  type InputToken,
6
+ type ParamCandidate,
7
+ type ParamLength,
4
8
  type ParamSpec,
5
9
  type ParsedCommandParams,
6
10
  } from './types.js'
@@ -101,9 +105,12 @@ export function tokenizeInput(input: string, usage: string): InputToken[] {
101
105
  return tokens
102
106
  }
103
107
 
104
- function enforceLength(spec: ParamSpec, value: string, usage: string): string {
105
- const length = spec.length
106
-
108
+ function enforceLength(
109
+ name: string,
110
+ length: ParamLength | undefined,
111
+ value: string,
112
+ usage: string
113
+ ): string {
107
114
  if (length === undefined) {
108
115
  return value
109
116
  }
@@ -112,14 +119,14 @@ function enforceLength(spec: ParamSpec, value: string, usage: string): string {
112
119
 
113
120
  if (length.min !== undefined && measured < length.min) {
114
121
  throw new CommandParamsError(
115
- `参数 ${spec.name} 至少需要 ${length.min} 个字符`,
122
+ `参数 ${name} 至少需要 ${length.min} 个字符`,
116
123
  usage
117
124
  )
118
125
  }
119
126
 
120
127
  if (length.max !== undefined && measured > length.max) {
121
128
  throw new CommandParamsError(
122
- `参数 ${spec.name} 不能超过 ${length.max} 个字符`,
129
+ `参数 ${name} 不能超过 ${length.max} 个字符`,
123
130
  usage
124
131
  )
125
132
  }
@@ -127,16 +134,27 @@ function enforceLength(spec: ParamSpec, value: string, usage: string): string {
127
134
  return value
128
135
  }
129
136
 
130
- export function parseValue(
137
+ function parseCandidate(
138
+ candidate: ParamCandidate,
131
139
  spec: ParamSpec,
132
140
  raw: string,
133
141
  usage: string
134
142
  ): unknown {
135
- switch (spec.type) {
143
+ if (candidate.kind === 'literal') {
144
+ if (raw === candidate.value) {
145
+ return raw
146
+ }
147
+
148
+ throw new CommandParamsError(
149
+ `参数 ${spec.name} 需要是 ${candidateLabel(candidate)}`,
150
+ usage
151
+ )
152
+ }
153
+
154
+ switch (candidate.type) {
136
155
  case 'string':
137
- return enforceLength(spec, raw, usage)
138
156
  case 'text':
139
- return raw
157
+ return enforceLength(spec.name, candidate.length, raw, usage)
140
158
  case 'number': {
141
159
  const value = raw.trim() === '' ? Number.NaN : Number(raw)
142
160
 
@@ -175,11 +193,41 @@ export function parseValue(
175
193
  }
176
194
  case 'at':
177
195
  return parseAt(raw, spec.name, usage)
196
+ case 'reply':
197
+ throw new CommandParamsError(
198
+ `参数 ${spec.name} 需要引用一条消息`,
199
+ usage
200
+ )
178
201
  case 'time':
179
202
  return parseTime(raw, spec.name, usage)
180
203
  }
181
204
  }
182
205
 
206
+ export function parseValue(
207
+ spec: ParamSpec,
208
+ raw: string,
209
+ usage: string
210
+ ): unknown {
211
+ if (spec.candidates.length === 1) {
212
+ return parseCandidate(spec.candidates[0]!, spec, raw, usage)
213
+ }
214
+
215
+ for (const candidate of spec.candidates) {
216
+ try {
217
+ return parseCandidate(candidate, spec, raw, usage)
218
+ } catch (error) {
219
+ if (!(error instanceof CommandParamsError)) {
220
+ throw error
221
+ }
222
+ }
223
+ }
224
+
225
+ throw new CommandParamsError(
226
+ `参数 ${spec.name} 需要是 ${joinCandidateLabels(spec.candidates)}`,
227
+ usage
228
+ )
229
+ }
230
+
183
231
  export function parseInput(
184
232
  specs: ParamSpec[],
185
233
  input: string,
@@ -191,7 +239,10 @@ export function parseInput(
191
239
  let index = 0
192
240
 
193
241
  for (const spec of specs) {
194
- if (spec.type === 'reply') {
242
+ const sole =
243
+ spec.candidates.length === 1 ? spec.candidates[0] : undefined
244
+
245
+ if (sole?.kind === 'type' && sole.type === 'reply') {
195
246
  if (quoteId !== undefined) {
196
247
  args.push(quoteId)
197
248
  } else if (spec.required) {
@@ -223,7 +274,7 @@ export function parseInput(
223
274
  continue
224
275
  }
225
276
 
226
- if (spec.type === 'text') {
277
+ if (sole?.kind === 'type' && sole.type === 'text') {
227
278
  const token = tokens[index]
228
279
 
229
280
  if (!token) {
@@ -245,7 +296,7 @@ export function parseInput(
245
296
  const value =
246
297
  remaining === 1 ? token.value : input.slice(token.start).trim()
247
298
 
248
- args.push(enforceLength(spec, value, usage))
299
+ args.push(enforceLength(spec.name, sole.length, value, usage))
249
300
  index = tokens.length
250
301
  continue
251
302
  }