@jsbot-tellus/core 2.0.3 → 2.1.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.
Files changed (78) hide show
  1. package/README.md +13 -17
  2. package/dist/app.js +12 -40
  3. package/dist/app.js.map +1 -1
  4. package/dist/core/config/schema.d.ts +0 -2
  5. package/dist/core/config/schema.d.ts.map +1 -1
  6. package/dist/core/config/schema.js +0 -1
  7. package/dist/core/config/schema.js.map +1 -1
  8. package/dist/core/dispatch/command.d.ts +24 -2
  9. package/dist/core/dispatch/command.d.ts.map +1 -1
  10. package/dist/core/dispatch/command.js +297 -60
  11. package/dist/core/dispatch/command.js.map +1 -1
  12. package/dist/core/dispatch/router.d.ts +17 -3
  13. package/dist/core/dispatch/router.d.ts.map +1 -1
  14. package/dist/core/dispatch/router.js +38 -2
  15. package/dist/core/dispatch/router.js.map +1 -1
  16. package/dist/core/index.d.ts +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/plugin/command-params.d.ts +15 -0
  19. package/dist/core/plugin/command-params.d.ts.map +1 -0
  20. package/dist/core/plugin/command-params.js +427 -0
  21. package/dist/core/plugin/command-params.js.map +1 -0
  22. package/dist/core/plugin/context.d.ts.map +1 -1
  23. package/dist/core/plugin/context.js +27 -0
  24. package/dist/core/plugin/context.js.map +1 -1
  25. package/dist/core/plugin/install-dir.d.ts +6 -9
  26. package/dist/core/plugin/install-dir.d.ts.map +1 -1
  27. package/dist/core/plugin/install-dir.js +99 -79
  28. package/dist/core/plugin/install-dir.js.map +1 -1
  29. package/dist/core/plugin/install-validate.d.ts +2 -1
  30. package/dist/core/plugin/install-validate.d.ts.map +1 -1
  31. package/dist/core/plugin/install-validate.js +25 -17
  32. package/dist/core/plugin/install-validate.js.map +1 -1
  33. package/dist/core/plugin/installer.d.ts +3 -3
  34. package/dist/core/plugin/installer.d.ts.map +1 -1
  35. package/dist/core/plugin/installer.js +143 -140
  36. package/dist/core/plugin/installer.js.map +1 -1
  37. package/dist/core/plugin/loader.d.ts +1 -0
  38. package/dist/core/plugin/loader.d.ts.map +1 -1
  39. package/dist/core/plugin/loader.js +24 -2
  40. package/dist/core/plugin/loader.js.map +1 -1
  41. package/dist/core/plugin/types.d.ts +11 -3
  42. package/dist/core/plugin/types.d.ts.map +1 -1
  43. package/dist/core/plugin/types.js.map +1 -1
  44. package/dist/plugins/admin/index.d.ts.map +1 -1
  45. package/dist/plugins/admin/index.js +119 -86
  46. package/dist/plugins/admin/index.js.map +1 -1
  47. package/dist/plugins/core/index.d.ts.map +1 -1
  48. package/dist/plugins/core/index.js +4 -1
  49. package/dist/plugins/core/index.js.map +1 -1
  50. package/docs/architecture.md +22 -24
  51. package/docs/configuration.md +19 -23
  52. package/docs/feature-parity.md +4 -4
  53. package/docs/migration.md +13 -13
  54. package/docs/plugin-api.md +156 -26
  55. package/docs/plugin-development.md +390 -0
  56. package/docs/release.md +2 -2
  57. package/examples/plugins/hello/README.md +10 -5
  58. package/examples/plugins/hello/index.js +3 -6
  59. package/package.json +2 -1
  60. package/src/app.ts +16 -55
  61. package/src/core/config/schema.ts +0 -1
  62. package/src/core/dispatch/command.ts +474 -70
  63. package/src/core/dispatch/router.ts +77 -3
  64. package/src/core/index.ts +2 -0
  65. package/src/core/plugin/command-params.ts +595 -0
  66. package/src/core/plugin/context.ts +47 -2
  67. package/src/core/plugin/install-dir.ts +117 -111
  68. package/src/core/plugin/install-validate.ts +36 -34
  69. package/src/core/plugin/installer.ts +228 -184
  70. package/src/core/plugin/loader.ts +36 -2
  71. package/src/core/plugin/types.ts +23 -3
  72. package/src/plugins/admin/index.ts +302 -233
  73. package/src/plugins/core/index.ts +5 -1
  74. package/dist/core/plugin/installed.d.ts +0 -9
  75. package/dist/core/plugin/installed.d.ts.map +0 -1
  76. package/dist/core/plugin/installed.js +0 -113
  77. package/dist/core/plugin/installed.js.map +0 -1
  78. package/src/core/plugin/installed.ts +0 -165
@@ -1,10 +1,20 @@
1
+ import type { OneBotMessageEvent } from '@snowluma/sdk'
2
+ import {
3
+ compileCommandParams,
4
+ type CompiledCommandParams,
5
+ } from '../plugin/command-params.js'
1
6
  import type {
7
+ CommandGroupOptions,
8
+ CommandGroupRegistrar,
2
9
  CommandHandler,
3
10
  CommandInfo,
4
11
  CommandMatch,
12
+ CommandOptions,
5
13
  } from '../plugin/types.js'
6
14
  import type { Permission } from '../security/permission.js'
7
15
 
16
+ type AnyCommandHandler = CommandHandler<OneBotMessageEvent, any[]>
17
+
8
18
  export interface CommandRegistration {
9
19
  pluginId: string
10
20
  name: string
@@ -12,7 +22,21 @@ export interface CommandRegistration {
12
22
  prefixes: string[]
13
23
  permission: Permission
14
24
  description?: string | undefined
15
- handler: CommandHandler
25
+ params?: string | undefined
26
+ paramsParser?: CompiledCommandParams | undefined
27
+ usage?: string | undefined
28
+ handler: AnyCommandHandler
29
+ }
30
+
31
+ export interface CommandGroupRegistration {
32
+ pluginId: string
33
+ name: string
34
+ aliases: string[]
35
+ prefixes: string[]
36
+ permission: Permission
37
+ description?: string | undefined
38
+ params?: string | undefined
39
+ handler?: AnyCommandHandler | undefined
16
40
  }
17
41
 
18
42
  export interface RegisteredCommand {
@@ -20,80 +44,167 @@ export interface RegisteredCommand {
20
44
  match: CommandMatch
21
45
  }
22
46
 
23
- export class CommandRegistry {
24
- private readonly index = new Map<string, Map<string, CommandRegistration>>()
25
- private readonly registrations = new Set<CommandRegistration>()
26
- private readonly byPlugin = new Map<string, Set<CommandRegistration>>()
47
+ interface GroupChild {
48
+ name: string
49
+ description?: string | undefined
50
+ usage?: string | undefined
51
+ }
27
52
 
28
- register(registration: CommandRegistration): () => void {
29
- const names = [
30
- ...new Set(
31
- [registration.name, ...registration.aliases].map((name) =>
32
- name.toLowerCase()
33
- )
34
- ),
35
- ]
36
- const prefixes = [...new Set(registration.prefixes)]
53
+ interface GroupScope {
54
+ registrar: CommandGroupRegistrar
55
+ disposeAll: () => void
56
+ track: (dispose: () => void, cleanup?: () => void) => () => void
57
+ }
37
58
 
38
- for (const prefix of prefixes) {
39
- for (const name of names) {
40
- const existing = this.index.get(prefix)?.get(name)
59
+ function normalizeSegment(label: string, value: string): string {
60
+ const trimmed = value.trim()
41
61
 
42
- if (existing) {
43
- throw new Error(`command conflict: ${prefix}${name}`)
44
- }
45
- }
46
- }
62
+ if (!trimmed) {
63
+ throw new Error(`${label} must not be empty`)
64
+ }
47
65
 
48
- for (const prefix of prefixes) {
49
- const bucket =
50
- this.index.get(prefix) ?? new Map<string, CommandRegistration>()
66
+ if (/\s/.test(trimmed)) {
67
+ throw new Error(
68
+ `${label} must be a single segment; use commands.group() for multi-level commands`
69
+ )
70
+ }
51
71
 
52
- for (const name of names) {
53
- bucket.set(name, registration)
54
- }
72
+ return trimmed
73
+ }
55
74
 
56
- this.index.set(prefix, bucket)
57
- }
75
+ function uniqueStrings(values: string[]): string[] {
76
+ const seen = new Set<string>()
77
+ const result: string[] = []
58
78
 
59
- this.registrations.add(registration)
79
+ for (const value of values) {
80
+ const key = value.toLowerCase()
60
81
 
61
- const pluginSet =
62
- this.byPlugin.get(registration.pluginId) ??
63
- new Set<CommandRegistration>()
64
- pluginSet.add(registration)
65
- this.byPlugin.set(registration.pluginId, pluginSet)
82
+ if (seen.has(key)) continue
66
83
 
67
- let disposed = false
84
+ seen.add(key)
85
+ result.push(value)
86
+ }
68
87
 
69
- return () => {
70
- if (disposed) return
88
+ return result
89
+ }
71
90
 
72
- disposed = true
91
+ function groupBases(name: string, aliases: string[]): string[] {
92
+ return uniqueStrings([
93
+ normalizeSegment('group name', name),
94
+ ...aliases.map((alias) => normalizeSegment('group alias', alias)),
95
+ ])
96
+ }
73
97
 
74
- for (const prefix of prefixes) {
75
- const bucket = this.index.get(prefix)
98
+ function composedBases(
99
+ parentBases: string[],
100
+ name: string,
101
+ aliases: string[],
102
+ kind: 'command' | 'group'
103
+ ): string[] {
104
+ const variants = uniqueStrings([
105
+ normalizeSegment(`${kind} name`, name),
106
+ ...aliases.map((alias) => normalizeSegment(`${kind} alias`, alias)),
107
+ ])
108
+ const bases: string[] = []
109
+ const seen = new Set<string>()
110
+
111
+ for (const parent of parentBases) {
112
+ for (const variant of variants) {
113
+ const path = `${parent} ${variant}`
114
+ const key = path.toLowerCase()
115
+
116
+ if (seen.has(key)) continue
117
+
118
+ seen.add(key)
119
+ bases.push(path)
120
+ }
121
+ }
76
122
 
77
- if (!bucket) continue
123
+ return bases
124
+ }
78
125
 
79
- for (const name of names) {
80
- if (bucket.get(name) === registration) {
81
- bucket.delete(name)
82
- }
83
- }
126
+ function createGroupUsageHandler(children: GroupChild[]): AnyCommandHandler {
127
+ return async (_event, context, match) => {
128
+ const root = `${match.prefix}${match.command}`
129
+ const names = children.map((child) => child.name)
130
+ const lines = [
131
+ names.length > 0
132
+ ? `用法:${root} <${names.join('|')}>`
133
+ : `用法:${root}`,
134
+ ]
84
135
 
85
- if (bucket.size === 0) {
86
- this.index.delete(prefix)
87
- }
88
- }
136
+ for (const child of children) {
137
+ const usage = child.usage !== undefined ? ` ${child.usage}` : ''
138
+ const command = `${root} ${child.name}${usage}`
89
139
 
90
- this.registrations.delete(registration)
91
- pluginSet.delete(registration)
140
+ lines.push(
141
+ child.description !== undefined
142
+ ? `${command} - ${child.description}`
143
+ : command
144
+ )
145
+ }
92
146
 
93
- if (pluginSet.size === 0) {
94
- this.byPlugin.delete(registration.pluginId)
95
- }
147
+ await context.send(lines.join('\n'))
148
+ }
149
+ }
150
+
151
+ export class CommandRegistry {
152
+ private readonly index = new Map<string, Map<string, CommandRegistration>>()
153
+ private readonly registrations = new Set<CommandRegistration>()
154
+ private readonly byPlugin = new Map<string, Set<CommandRegistration>>()
155
+
156
+ register(registration: CommandRegistration): () => void {
157
+ const name = normalizeSegment('command name', registration.name)
158
+ const aliases = uniqueStrings(
159
+ registration.aliases.map((alias) =>
160
+ normalizeSegment('command alias', alias)
161
+ )
162
+ ).filter((alias) => alias.toLowerCase() !== name.toLowerCase())
163
+ const normalized = this.withParams({
164
+ ...registration,
165
+ name,
166
+ aliases,
167
+ })
168
+
169
+ return this.insert(normalized, [name, ...aliases])
170
+ }
171
+
172
+ group(
173
+ registration: CommandGroupRegistration,
174
+ setup: (group: CommandGroupRegistrar) => void
175
+ ): () => void {
176
+ const prefixes = uniqueStrings(registration.prefixes)
177
+ const bases = groupBases(registration.name, registration.aliases)
178
+ const children: GroupChild[] = []
179
+ const scope = this.createGroupScope(
180
+ registration.pluginId,
181
+ bases,
182
+ prefixes,
183
+ registration.permission,
184
+ children
185
+ )
186
+
187
+ try {
188
+ setup(scope.registrar)
189
+
190
+ const dispose = this.registerGroupRoot(
191
+ registration.pluginId,
192
+ bases,
193
+ prefixes,
194
+ registration.permission,
195
+ registration.description,
196
+ registration.params,
197
+ registration.handler,
198
+ children
199
+ )
200
+
201
+ scope.track(dispose)
202
+ } catch (error) {
203
+ scope.disposeAll()
204
+ throw error
96
205
  }
206
+
207
+ return scope.disposeAll
97
208
  }
98
209
 
99
210
  removePlugin(pluginId: string): void {
@@ -102,12 +213,11 @@ export class CommandRegistry {
102
213
  if (!registrations) return
103
214
 
104
215
  for (const registration of [...registrations]) {
105
- const prefixes = registration.prefixes
106
216
  const names = [registration.name, ...registration.aliases].map(
107
217
  (name) => name.toLowerCase()
108
218
  )
109
219
 
110
- for (const prefix of prefixes) {
220
+ for (const prefix of registration.prefixes) {
111
221
  const bucket = this.index.get(prefix)
112
222
 
113
223
  if (!bucket) continue
@@ -139,6 +249,9 @@ export class CommandRegistry {
139
249
  ...(registration.description !== undefined
140
250
  ? { description: registration.description }
141
251
  : {}),
252
+ ...(registration.usage !== undefined
253
+ ? { usage: registration.usage }
254
+ : {}),
142
255
  pluginId: registration.pluginId,
143
256
  }))
144
257
  }
@@ -159,22 +272,313 @@ export class CommandRegistry {
159
272
 
160
273
  if (!text) continue
161
274
 
162
- const [head = '', ...args] = text.split(/\s+/)
163
- const registration = this.index.get(prefix)?.get(head.toLowerCase())
275
+ const tokens = [...text.matchAll(/\S+/g)]
276
+
277
+ if (tokens.length === 0) continue
278
+
279
+ for (let count = tokens.length; count >= 1; count -= 1) {
280
+ const matched = tokens.slice(0, count)
281
+ const first = matched[0]
282
+ const last = matched[matched.length - 1]
283
+
284
+ if (!first || !last) continue
285
+
286
+ const key = matched
287
+ .map((token) => token[0])
288
+ .join(' ')
289
+ .toLowerCase()
290
+ const registration = this.index.get(prefix)?.get(key)
164
291
 
165
- if (!registration) continue
292
+ if (!registration) continue
166
293
 
167
- return {
168
- registration,
169
- match: {
170
- command: head,
171
- prefix,
172
- args,
173
- rest: text.slice(head.length).trim(),
174
- },
294
+ const start = first.index ?? 0
295
+ const end = (last.index ?? 0) + last[0].length
296
+
297
+ return {
298
+ registration,
299
+ match: {
300
+ command: text
301
+ .slice(start, end)
302
+ .replace(/\s+/g, ' ')
303
+ .trim(),
304
+ prefix,
305
+ args: tokens.slice(count).map((token) => token[0]),
306
+ rest: text.slice(end).trim(),
307
+ },
308
+ }
175
309
  }
176
310
  }
177
311
 
178
312
  return null
179
313
  }
314
+
315
+ private withParams(registration: CommandRegistration): CommandRegistration {
316
+ if (registration.params === undefined) return registration
317
+
318
+ const compiled = compileCommandParams(registration.params)
319
+
320
+ return {
321
+ ...registration,
322
+ paramsParser: compiled,
323
+ usage: compiled.usage,
324
+ }
325
+ }
326
+
327
+ private insert(
328
+ registration: CommandRegistration,
329
+ paths: string[]
330
+ ): () => void {
331
+ const prefixes = uniqueStrings(registration.prefixes)
332
+ const names = uniqueStrings(paths)
333
+
334
+ for (const prefix of prefixes) {
335
+ for (const name of names) {
336
+ const existing = this.index.get(prefix)?.get(name.toLowerCase())
337
+
338
+ if (existing) {
339
+ throw new Error(`command conflict: ${prefix}${name}`)
340
+ }
341
+ }
342
+ }
343
+
344
+ for (const prefix of prefixes) {
345
+ const bucket =
346
+ this.index.get(prefix) ?? new Map<string, CommandRegistration>()
347
+
348
+ for (const name of names) {
349
+ bucket.set(name.toLowerCase(), registration)
350
+ }
351
+
352
+ this.index.set(prefix, bucket)
353
+ }
354
+
355
+ this.registrations.add(registration)
356
+
357
+ const pluginSet =
358
+ this.byPlugin.get(registration.pluginId) ??
359
+ new Set<CommandRegistration>()
360
+ pluginSet.add(registration)
361
+ this.byPlugin.set(registration.pluginId, pluginSet)
362
+
363
+ let disposed = false
364
+
365
+ return () => {
366
+ if (disposed) return
367
+
368
+ disposed = true
369
+
370
+ for (const prefix of prefixes) {
371
+ const bucket = this.index.get(prefix)
372
+
373
+ if (!bucket) continue
374
+
375
+ for (const name of names) {
376
+ if (bucket.get(name.toLowerCase()) === registration) {
377
+ bucket.delete(name.toLowerCase())
378
+ }
379
+ }
380
+
381
+ if (bucket.size === 0) {
382
+ this.index.delete(prefix)
383
+ }
384
+ }
385
+
386
+ this.registrations.delete(registration)
387
+ pluginSet.delete(registration)
388
+
389
+ if (pluginSet.size === 0) {
390
+ this.byPlugin.delete(registration.pluginId)
391
+ }
392
+ }
393
+ }
394
+
395
+ private createGroupScope(
396
+ pluginId: string,
397
+ bases: string[],
398
+ prefixes: string[],
399
+ permission: Permission,
400
+ children: GroupChild[]
401
+ ): GroupScope {
402
+ const disposers: Array<() => void> = []
403
+ let disposed = false
404
+
405
+ const track = (
406
+ dispose: () => void,
407
+ cleanup?: () => void
408
+ ): (() => void) => {
409
+ let done = false
410
+
411
+ const tracked = (): void => {
412
+ if (done) return
413
+
414
+ done = true
415
+ cleanup?.()
416
+ dispose()
417
+ }
418
+
419
+ disposers.push(tracked)
420
+
421
+ return tracked
422
+ }
423
+
424
+ const disposeAll = (): void => {
425
+ if (disposed) return
426
+
427
+ disposed = true
428
+
429
+ for (const dispose of disposers.splice(0).reverse()) {
430
+ dispose()
431
+ }
432
+ }
433
+
434
+ const registrar: CommandGroupRegistrar = {
435
+ register: <TArgs extends unknown[] = []>(
436
+ options: CommandOptions,
437
+ handler: CommandHandler<OneBotMessageEvent, TArgs>
438
+ ) => {
439
+ const childBases = composedBases(
440
+ bases,
441
+ options.name,
442
+ options.aliases ?? [],
443
+ 'command'
444
+ )
445
+ const name = childBases[0]
446
+
447
+ if (!name) {
448
+ throw new Error('command name must not be empty')
449
+ }
450
+
451
+ const registration = this.withParams({
452
+ pluginId,
453
+ name,
454
+ aliases: childBases.slice(1),
455
+ prefixes: options.prefixes ?? prefixes,
456
+ permission: options.permission ?? permission,
457
+ ...(options.description !== undefined
458
+ ? { description: options.description }
459
+ : {}),
460
+ ...(options.params !== undefined
461
+ ? { params: options.params }
462
+ : {}),
463
+ handler,
464
+ })
465
+ const dispose = this.insert(registration, childBases)
466
+ const record: GroupChild = {
467
+ name: options.name.trim(),
468
+ ...(options.description !== undefined
469
+ ? { description: options.description }
470
+ : {}),
471
+ ...(registration.usage !== undefined
472
+ ? { usage: registration.usage }
473
+ : {}),
474
+ }
475
+
476
+ children.push(record)
477
+
478
+ return track(dispose, () => {
479
+ const index = children.indexOf(record)
480
+
481
+ if (index >= 0) {
482
+ children.splice(index, 1)
483
+ }
484
+ })
485
+ },
486
+
487
+ group: <TArgs extends unknown[] = []>(
488
+ options: CommandGroupOptions<TArgs>,
489
+ nestedSetup: (group: CommandGroupRegistrar) => void
490
+ ) => {
491
+ const nestedBases = composedBases(
492
+ bases,
493
+ options.name,
494
+ options.aliases ?? [],
495
+ 'group'
496
+ )
497
+ const nestedPrefixes = options.prefixes ?? prefixes
498
+ const nestedPermission = options.permission ?? permission
499
+ const nestedChildren: GroupChild[] = []
500
+ const nestedScope = this.createGroupScope(
501
+ pluginId,
502
+ nestedBases,
503
+ nestedPrefixes,
504
+ nestedPermission,
505
+ nestedChildren
506
+ )
507
+
508
+ try {
509
+ nestedSetup(nestedScope.registrar)
510
+
511
+ const dispose = this.registerGroupRoot(
512
+ pluginId,
513
+ nestedBases,
514
+ nestedPrefixes,
515
+ nestedPermission,
516
+ options.description,
517
+ options.params,
518
+ options.handler,
519
+ nestedChildren
520
+ )
521
+
522
+ nestedScope.track(dispose)
523
+ } catch (error) {
524
+ nestedScope.disposeAll()
525
+ throw error
526
+ }
527
+
528
+ const record: GroupChild = {
529
+ name: options.name.trim(),
530
+ ...(options.description !== undefined
531
+ ? { description: options.description }
532
+ : {}),
533
+ ...(options.params !== undefined
534
+ ? {
535
+ usage: compileCommandParams(options.params).usage,
536
+ }
537
+ : {}),
538
+ }
539
+
540
+ children.push(record)
541
+
542
+ return track(nestedScope.disposeAll, () => {
543
+ const index = children.indexOf(record)
544
+
545
+ if (index >= 0) {
546
+ children.splice(index, 1)
547
+ }
548
+ })
549
+ },
550
+ }
551
+
552
+ return { registrar, disposeAll, track }
553
+ }
554
+
555
+ private registerGroupRoot(
556
+ pluginId: string,
557
+ bases: string[],
558
+ prefixes: string[],
559
+ permission: Permission,
560
+ description: string | undefined,
561
+ params: string | undefined,
562
+ handler: AnyCommandHandler | undefined,
563
+ children: GroupChild[]
564
+ ): () => void {
565
+ const name = bases[0]
566
+
567
+ if (!name) {
568
+ throw new Error('group name must not be empty')
569
+ }
570
+
571
+ const registration = this.withParams({
572
+ pluginId,
573
+ name,
574
+ aliases: bases.slice(1),
575
+ prefixes,
576
+ permission,
577
+ ...(description !== undefined ? { description } : {}),
578
+ ...(params !== undefined ? { params } : {}),
579
+ handler: handler ?? createGroupUsageHandler(children),
580
+ })
581
+
582
+ return this.insert(registration, bases)
583
+ }
180
584
  }