@jsbot-tellus/core 2.1.0 → 2.3.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 (41) hide show
  1. package/README.md +14 -14
  2. package/dist/app.js +4 -2
  3. package/dist/app.js.map +1 -1
  4. package/dist/core/dispatch/command.d.ts.map +1 -1
  5. package/dist/core/dispatch/command.js.map +1 -1
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/index.d.ts.map +1 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/kernel.d.ts.map +1 -1
  10. package/dist/core/kernel.js +5 -1
  11. package/dist/core/kernel.js.map +1 -1
  12. package/dist/core/plugin/command-params.d.ts +23 -1
  13. package/dist/core/plugin/command-params.d.ts.map +1 -1
  14. package/dist/core/plugin/command-params.js +35 -0
  15. package/dist/core/plugin/command-params.js.map +1 -1
  16. package/dist/core/plugin/context.d.ts.map +1 -1
  17. package/dist/core/plugin/context.js.map +1 -1
  18. package/dist/core/plugin/loader.d.ts +13 -2
  19. package/dist/core/plugin/loader.d.ts.map +1 -1
  20. package/dist/core/plugin/loader.js +87 -5
  21. package/dist/core/plugin/loader.js.map +1 -1
  22. package/dist/core/plugin/types.d.ts +8 -7
  23. package/dist/core/plugin/types.d.ts.map +1 -1
  24. package/dist/core/plugin/types.js.map +1 -1
  25. package/dist/plugins/core/index.js +1 -1
  26. package/dist/plugins/core/index.js.map +1 -1
  27. package/docs/architecture.md +3 -3
  28. package/docs/configuration.md +3 -3
  29. package/docs/feature-parity.md +2 -2
  30. package/docs/plugin-api.md +12 -4
  31. package/docs/plugin-development.md +34 -24
  32. package/package.json +3 -1
  33. package/src/app.ts +4 -2
  34. package/src/core/dispatch/command.ts +9 -5
  35. package/src/core/index.ts +1 -0
  36. package/src/core/kernel.ts +8 -1
  37. package/src/core/plugin/command-params.ts +119 -1
  38. package/src/core/plugin/context.ts +9 -5
  39. package/src/core/plugin/loader.ts +150 -7
  40. package/src/core/plugin/types.ts +17 -11
  41. package/src/plugins/core/index.ts +1 -1
@@ -1,5 +1,7 @@
1
+ import { parseSegments } from '@snowluma/sdk'
2
+
1
3
  export type CommandParamType =
2
- 'string' | 'text' | 'number' | 'integer' | 'boolean'
4
+ 'string' | 'text' | 'number' | 'integer' | 'boolean' | 'at'
3
5
 
4
6
  export interface ParsedCommandParams {
5
7
  args: unknown[]
@@ -48,6 +50,7 @@ function parseType(value: string): CommandParamType {
48
50
  case 'number':
49
51
  case 'integer':
50
52
  case 'boolean':
53
+ case 'at':
51
54
  return value
52
55
  default:
53
56
  throw declarationError(`未知的参数类型 "${value}"`)
@@ -177,6 +180,35 @@ function parseBoolean(value: string): boolean | null {
177
180
  return null
178
181
  }
179
182
 
183
+ function parseAt(value: string, name: string, usage: string): number {
184
+ const raw = value.trim()
185
+ const segments = parseSegments(raw)
186
+ const segment = segments[0]
187
+
188
+ if (segments.length === 1 && segment?.type === 'at') {
189
+ const qq = segment.data.qq
190
+
191
+ if (qq !== 'all') {
192
+ const parsed = Number(qq)
193
+
194
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
195
+ return parsed
196
+ }
197
+ }
198
+ } else if (/^\d+$/.test(raw)) {
199
+ const parsed = Number(raw)
200
+
201
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
202
+ return parsed
203
+ }
204
+ }
205
+
206
+ throw new CommandParamsError(
207
+ `参数 ${name} 需要 @ 一个用户或填写 QQ 号`,
208
+ usage
209
+ )
210
+ }
211
+
180
212
  function parseDefaultValue(
181
213
  type: CommandParamType,
182
214
  raw: string,
@@ -215,6 +247,8 @@ function parseDefaultValue(
215
247
 
216
248
  return boolean
217
249
  }
250
+ case 'at':
251
+ throw declarationError(`参数 ${name} 不支持默认值`)
218
252
  }
219
253
  }
220
254
 
@@ -405,6 +439,16 @@ function tokenizeInput(input: string, usage: string): InputToken[] {
405
439
 
406
440
  if (/\s/.test(current)) break
407
441
 
442
+ if (current === '[' && input.startsWith('[CQ:', index)) {
443
+ const close = input.indexOf(']', index + 4)
444
+
445
+ if (close >= 0) {
446
+ value += input.slice(index, close + 1)
447
+ index = close + 1
448
+ continue
449
+ }
450
+ }
451
+
408
452
  if (current === '"' || current === "'") {
409
453
  quote = current
410
454
  index += 1
@@ -480,6 +524,8 @@ function parseValue(spec: ParamSpec, raw: string, usage: string): unknown {
480
524
 
481
525
  return value
482
526
  }
527
+ case 'at':
528
+ return parseAt(raw, spec.name, usage)
483
529
  }
484
530
  }
485
531
 
@@ -593,3 +639,75 @@ export function compileCommandParams(
593
639
  },
594
640
  }
595
641
  }
642
+
643
+ type Whitespace = ' ' | '\n' | '\r' | '\t' | '\v' | '\f'
644
+
645
+ type TrimLeft<Source extends string> =
646
+ Source extends `${Whitespace}${infer Rest}` ? TrimLeft<Rest> : Source
647
+
648
+ type TrimRight<Source extends string> =
649
+ Source extends `${infer Rest}${Whitespace}` ? TrimRight<Rest> : Source
650
+
651
+ type Trim<Source extends string> = TrimLeft<TrimRight<Source>>
652
+
653
+ type ParamValue<Type extends string> =
654
+ Trim<Type> extends 'string' | 'text'
655
+ ? string
656
+ : Trim<Type> extends 'number' | 'integer' | 'at'
657
+ ? number
658
+ : Trim<Type> extends 'boolean'
659
+ ? boolean
660
+ : unknown
661
+
662
+ interface VariadicParam {
663
+ readonly variadic: true
664
+ }
665
+
666
+ type ParamType<Head extends string> = Head extends `${string}:${infer Type}`
667
+ ? ParamValue<Type>
668
+ : string
669
+
670
+ type ParamElement<Body extends string, Required extends boolean> =
671
+ Trim<Body> extends `...${string}`
672
+ ? VariadicParam
673
+ : Trim<Body> extends `${infer Head}=${string}`
674
+ ? ParamType<Head>
675
+ : Required extends true
676
+ ? ParamType<Body>
677
+ : ParamType<Body> | undefined
678
+
679
+ type AppendParam<Acc extends unknown[], Element> = [Element] extends [
680
+ VariadicParam,
681
+ ]
682
+ ? [...Acc, ...string[]]
683
+ : [...Acc, Element]
684
+
685
+ type ParseCommandParams<Source extends string, Acc extends unknown[] = []> =
686
+ Trim<Source> extends ''
687
+ ? Acc
688
+ : Trim<Source> extends `<${infer Body}>${infer Rest}`
689
+ ? ParamElement<Body, true> extends VariadicParam
690
+ ? any[]
691
+ : ParseCommandParams<
692
+ Rest,
693
+ AppendParam<Acc, ParamElement<Body, true>>
694
+ >
695
+ : Trim<Source> extends `[${infer Body}]${infer Rest}`
696
+ ? ParamElement<Body, false> extends VariadicParam
697
+ ? Trim<Rest> extends ''
698
+ ? [...Acc, ...string[]]
699
+ : any[]
700
+ : ParseCommandParams<
701
+ Rest,
702
+ AppendParam<Acc, ParamElement<Body, false>>
703
+ >
704
+ : any[]
705
+
706
+ /**
707
+ * 把 `params` 声明字符串推导成 handler 的位置参数 tuple。
708
+ *
709
+ * 非字面量声明(例如运行时拼接的 `string`)或无法解析的声明会回退为
710
+ * `any[]`,以保证现有插件仍然可以编译。
711
+ */
712
+ export type CommandParamArgs<Declaration extends string> =
713
+ string extends Declaration ? any[] : ParseCommandParams<Declaration>
@@ -4,6 +4,7 @@ import type { Router } from '../dispatch/router.js'
4
4
  import type { TaskQueue } from '../dispatch/task-queue.js'
5
5
  import type { Logger } from '../logging/logger.js'
6
6
  import type { SessionStore } from '../storage/session.js'
7
+ import type { CommandParamArgs } from './command-params.js'
7
8
  import type { PluginKV } from './kv.js'
8
9
  import type {
9
10
  CommandGroupOptions,
@@ -43,9 +44,12 @@ export function createPluginContext<TConfig>(
43
44
  core: deps.core,
44
45
 
45
46
  commands: {
46
- register<TArgs extends unknown[] = []>(
47
- options: CommandOptions,
48
- handler: CommandHandler<OneBotMessageEvent, TArgs>
47
+ register<Declaration extends string = string>(
48
+ options: CommandOptions<Declaration>,
49
+ handler: CommandHandler<
50
+ OneBotMessageEvent,
51
+ CommandParamArgs<Declaration>
52
+ >
49
53
  ) {
50
54
  return deps.router.registerCommand({
51
55
  pluginId: id,
@@ -68,8 +72,8 @@ export function createPluginContext<TConfig>(
68
72
  handler,
69
73
  })
70
74
  },
71
- group<TArgs extends unknown[] = []>(
72
- options: CommandGroupOptions<TArgs>,
75
+ group<Declaration extends string = string>(
76
+ options: CommandGroupOptions<Declaration>,
73
77
  setup: (group: CommandGroupRegistrar) => void
74
78
  ) {
75
79
  return deps.router.registerCommandGroup(
@@ -4,9 +4,103 @@ import { pathToFileURL } from 'node:url'
4
4
  import fg from 'fast-glob'
5
5
  import type { Logger } from '../logging/logger.js'
6
6
  import { collectPluginExports } from './exports.js'
7
+ import { readInstallState, readJsonFile } from './install-dir.js'
8
+ import { NpmCliRunner, type NpmRunner } from './npm-runner.js'
7
9
  import type { AnyPluginDefinition } from './types.js'
8
10
 
9
- type LoaderLogger = Pick<Logger, 'warn' | 'error'>
11
+ type LoaderLogger = Pick<Logger, 'warn' | 'error'> & {
12
+ info?: Logger['info'] | undefined
13
+ }
14
+
15
+ export interface LocalPluginLoaderOptions {
16
+ /** Build local plugins that have a build script but no dist. Defaults to true. */
17
+ autoBuild?: boolean | undefined
18
+ /** Timeout for a single local plugin build. Defaults to 120 seconds. */
19
+ buildTimeoutMs?: number | undefined
20
+ /** npm runner override, mainly for tests. */
21
+ runner?: NpmRunner | undefined
22
+ }
23
+
24
+ const DEFAULT_BUILD_TIMEOUT_MS = 120_000
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
28
+ }
29
+
30
+ async function directoryExists(path: string): Promise<boolean> {
31
+ const info = await stat(path).catch(() => null)
32
+
33
+ return info?.isDirectory() ?? false
34
+ }
35
+
36
+ async function readBuildScript(directory: string): Promise<string | undefined> {
37
+ const manifest = await readJsonFile(join(directory, 'package.json')).catch(
38
+ () => undefined
39
+ )
40
+
41
+ if (!isRecord(manifest) || !isRecord(manifest['scripts'])) {
42
+ return undefined
43
+ }
44
+
45
+ const build = manifest['scripts']['build']
46
+
47
+ return typeof build === 'string' && build.trim().length > 0
48
+ ? build
49
+ : undefined
50
+ }
51
+
52
+ async function readManagedDirectories(
53
+ root: string
54
+ ): Promise<Set<string> | null> {
55
+ try {
56
+ const state = await readInstallState(root)
57
+
58
+ return new Set(
59
+ Object.values(state.packages).map((record) => record.dir)
60
+ )
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ async function buildLocalPlugin(
67
+ directory: string,
68
+ name: string,
69
+ logger: LoaderLogger | undefined,
70
+ runner: NpmRunner,
71
+ timeoutMs: number
72
+ ): Promise<boolean> {
73
+ if ((await readBuildScript(directory)) === undefined) {
74
+ logger?.warn(
75
+ `skipping local plugin ${name}: dist directory not found and package.json has no build script`
76
+ )
77
+ return false
78
+ }
79
+
80
+ if (!(await directoryExists(join(directory, 'node_modules')))) {
81
+ logger?.warn(
82
+ `skipping local plugin ${name}: dist directory not found and node_modules is missing; run npm install --prefix ${directory}`
83
+ )
84
+ return false
85
+ }
86
+
87
+ logger?.info?.(`building local plugin ${name}: dist directory not found`)
88
+
89
+ try {
90
+ await runner.run(['run', 'build'], {
91
+ cwd: directory,
92
+ timeoutMs,
93
+ })
94
+ } catch (error) {
95
+ logger?.error(`failed to build local plugin ${name}`, error)
96
+ logger?.warn(`skipping local plugin ${name}: build failed`)
97
+ return false
98
+ }
99
+
100
+ logger?.info?.(`built local plugin ${name}`)
101
+
102
+ return true
103
+ }
10
104
 
11
105
  export async function loadPluginsFromDirectory(
12
106
  directory: string,
@@ -63,7 +157,8 @@ export async function loadPluginsFromDirectory(
63
157
 
64
158
  export async function loadLocalPlugins(
65
159
  root: string,
66
- logger?: LoaderLogger | undefined
160
+ logger?: LoaderLogger | undefined,
161
+ options: LocalPluginLoaderOptions = {}
67
162
  ): Promise<AnyPluginDefinition[]> {
68
163
  const absolute = resolve(root)
69
164
  const info = await stat(absolute).catch(() => null)
@@ -75,18 +170,66 @@ export async function loadLocalPlugins(
75
170
  )
76
171
  const plugins: AnyPluginDefinition[] = []
77
172
 
173
+ let autoBuild = options.autoBuild !== false
174
+ let managed = new Set<string>()
175
+
176
+ if (autoBuild) {
177
+ const directories = await readManagedDirectories(absolute)
178
+
179
+ if (directories === null) {
180
+ logger?.warn(
181
+ 'cannot read plugin install state; automatic local plugin build is disabled'
182
+ )
183
+ autoBuild = false
184
+ } else {
185
+ managed = directories
186
+ }
187
+ }
188
+
189
+ const runner = options.runner ?? new NpmCliRunner()
190
+ const timeoutMs = options.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS
191
+
78
192
  for (const entry of entries) {
79
193
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue
80
194
  if (entry.name === 'node_modules') continue
81
195
 
82
- const dist = join(absolute, entry.name, 'dist')
83
- const distInfo = await stat(dist).catch(() => null)
196
+ const directory = join(absolute, entry.name)
197
+ const dist = join(directory, 'dist')
198
+ let distInfo = await stat(dist).catch(() => null)
84
199
 
85
200
  if (!distInfo?.isDirectory()) {
86
- logger?.warn(
87
- `skipping local plugin ${entry.name}: dist directory not found`
201
+ if (!autoBuild) {
202
+ logger?.warn(
203
+ `skipping local plugin ${entry.name}: dist directory not found`
204
+ )
205
+ continue
206
+ }
207
+
208
+ if (managed.has(entry.name)) {
209
+ logger?.warn(
210
+ `skipping local plugin ${entry.name}: dist directory not found (installed package is expected to include dist)`
211
+ )
212
+ continue
213
+ }
214
+
215
+ const built = await buildLocalPlugin(
216
+ directory,
217
+ entry.name,
218
+ logger,
219
+ runner,
220
+ timeoutMs
88
221
  )
89
- continue
222
+
223
+ if (!built) continue
224
+
225
+ distInfo = await stat(dist).catch(() => null)
226
+
227
+ if (!distInfo?.isDirectory()) {
228
+ logger?.warn(
229
+ `skipping local plugin ${entry.name}: build finished but dist directory not found`
230
+ )
231
+ continue
232
+ }
90
233
  }
91
234
 
92
235
  plugins.push(...(await loadPluginsFromDirectory(dist, logger)))
@@ -13,6 +13,7 @@ import type { SendOptions } from '../messaging/sender.js'
13
13
  import type { PluginKV } from '../plugin/kv.js'
14
14
  import type { Permission } from '../security/permission.js'
15
15
  import type { SessionStore } from '../storage/session.js'
16
+ import type { CommandParamArgs } from './command-params.js'
16
17
 
17
18
  export type EventType = 'message' | 'notice' | 'request' | 'meta'
18
19
 
@@ -74,28 +75,33 @@ export type PluginMiddleware = (
74
75
  next: () => Promise<void>
75
76
  ) => void | Promise<void>
76
77
 
77
- export interface CommandOptions {
78
+ export interface CommandOptions<Declaration extends string = string> {
78
79
  name: string
79
80
  aliases?: string[] | undefined
80
81
  prefixes?: string[] | undefined
81
82
  permission?: Permission | undefined
82
83
  description?: string | undefined
83
- params?: string | undefined
84
+ params?: Declaration | undefined
84
85
  }
85
86
 
86
87
  export interface CommandGroupOptions<
87
- TArgs extends unknown[] = [],
88
- > extends CommandOptions {
89
- handler?: CommandHandler<OneBotMessageEvent, TArgs> | undefined
88
+ Declaration extends string = string,
89
+ > extends CommandOptions<Declaration> {
90
+ handler?:
91
+ | CommandHandler<OneBotMessageEvent, CommandParamArgs<Declaration>>
92
+ | undefined
90
93
  }
91
94
 
92
95
  export interface CommandGroupRegistrar {
93
- register<TArgs extends unknown[] = []>(
94
- options: CommandOptions,
95
- handler: CommandHandler<OneBotMessageEvent, TArgs>
96
+ register<Declaration extends string = string>(
97
+ options: CommandOptions<Declaration>,
98
+ handler: CommandHandler<
99
+ OneBotMessageEvent,
100
+ CommandParamArgs<Declaration>
101
+ >
96
102
  ): () => void
97
- group<TArgs extends unknown[] = []>(
98
- options: CommandGroupOptions<TArgs>,
103
+ group<Declaration extends string = string>(
104
+ options: CommandGroupOptions<Declaration>,
99
105
  setup: (group: CommandGroupRegistrar) => void
100
106
  ): () => void
101
107
  }
@@ -184,7 +190,7 @@ export interface CoreServices {
184
190
  can(actual: Permission, required: Permission): boolean
185
191
  }
186
192
  commands: {
187
- list(): ReadonlyArray<CommandInfo>
193
+ list(event?: SnowLumaEvent): ReadonlyArray<CommandInfo>
188
194
  }
189
195
  plugins: PluginStatus
190
196
  installer: PluginInstaller
@@ -38,7 +38,7 @@ export const corePlugin = definePlugin({
38
38
  async (event, context) => {
39
39
  const role = ctx.core.roles.roleOf(event.user_id)
40
40
  const commands = ctx.core.commands
41
- .list()
41
+ .list(event)
42
42
  .filter((command) =>
43
43
  ctx.core.roles.can(role, command.permission)
44
44
  )