@jsbot-tellus/core 2.3.0 → 2.5.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 (120) hide show
  1. package/README.md +19 -0
  2. package/bot.config.example.json +3 -0
  3. package/dist/app.js +215 -98
  4. package/dist/app.js.map +1 -1
  5. package/dist/core/client.d.ts +1 -0
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +3 -0
  8. package/dist/core/client.js.map +1 -1
  9. package/dist/core/config/schema.d.ts +19 -0
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +37 -0
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/dispatch/command.d.ts.map +1 -1
  14. package/dist/core/dispatch/command.js +5 -1
  15. package/dist/core/dispatch/command.js.map +1 -1
  16. package/dist/core/dispatch/router.d.ts +3 -0
  17. package/dist/core/dispatch/router.d.ts.map +1 -1
  18. package/dist/core/dispatch/router.js +14 -1
  19. package/dist/core/dispatch/router.js.map +1 -1
  20. package/dist/core/index.d.ts +2 -2
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/kernel.d.ts +8 -0
  24. package/dist/core/kernel.d.ts.map +1 -1
  25. package/dist/core/kernel.js +17 -0
  26. package/dist/core/kernel.js.map +1 -1
  27. package/dist/core/logging/log-buffer.d.ts +27 -0
  28. package/dist/core/logging/log-buffer.d.ts.map +1 -0
  29. package/dist/core/logging/log-buffer.js +71 -0
  30. package/dist/core/logging/log-buffer.js.map +1 -0
  31. package/dist/core/logging/logger.d.ts +3 -0
  32. package/dist/core/logging/logger.d.ts.map +1 -1
  33. package/dist/core/logging/logger.js +6 -3
  34. package/dist/core/logging/logger.js.map +1 -1
  35. package/dist/core/plugin/command-params.d.ts +4 -3
  36. package/dist/core/plugin/command-params.d.ts.map +1 -1
  37. package/dist/core/plugin/command-params.js +122 -3
  38. package/dist/core/plugin/command-params.js.map +1 -1
  39. package/dist/core/storage/session.d.ts +15 -0
  40. package/dist/core/storage/session.d.ts.map +1 -1
  41. package/dist/core/storage/session.js +55 -0
  42. package/dist/core/storage/session.js.map +1 -1
  43. package/dist/core/web/api.d.ts +16 -0
  44. package/dist/core/web/api.d.ts.map +1 -0
  45. package/dist/core/web/api.js +617 -0
  46. package/dist/core/web/api.js.map +1 -0
  47. package/dist/core/web/auth.d.ts +43 -0
  48. package/dist/core/web/auth.d.ts.map +1 -0
  49. package/dist/core/web/auth.js +161 -0
  50. package/dist/core/web/auth.js.map +1 -0
  51. package/dist/core/web/config-store.d.ts +30 -0
  52. package/dist/core/web/config-store.d.ts.map +1 -0
  53. package/dist/core/web/config-store.js +216 -0
  54. package/dist/core/web/config-store.js.map +1 -0
  55. package/dist/core/web/event-feed.d.ts +26 -0
  56. package/dist/core/web/event-feed.d.ts.map +1 -0
  57. package/dist/core/web/event-feed.js +107 -0
  58. package/dist/core/web/event-feed.js.map +1 -0
  59. package/dist/core/web/http.d.ts +39 -0
  60. package/dist/core/web/http.d.ts.map +1 -0
  61. package/dist/core/web/http.js +233 -0
  62. package/dist/core/web/http.js.map +1 -0
  63. package/dist/core/web/index.d.ts +11 -0
  64. package/dist/core/web/index.d.ts.map +1 -0
  65. package/dist/core/web/index.js +6 -0
  66. package/dist/core/web/index.js.map +1 -0
  67. package/dist/core/web/server.d.ts +30 -0
  68. package/dist/core/web/server.d.ts.map +1 -0
  69. package/dist/core/web/server.js +437 -0
  70. package/dist/core/web/server.js.map +1 -0
  71. package/dist/core/web/types.d.ts +84 -0
  72. package/dist/core/web/types.d.ts.map +1 -0
  73. package/dist/core/web/types.js +2 -0
  74. package/dist/core/web/types.js.map +1 -0
  75. package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
  76. package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
  77. package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
  78. package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
  79. package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
  80. package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
  81. package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
  82. package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
  83. package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
  84. package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
  85. package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
  86. package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
  87. package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
  88. package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
  89. package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
  90. package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
  91. package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
  92. package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
  93. package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
  94. package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
  95. package/dist/webui/assets/api-CNzxhTWe.js +3 -0
  96. package/dist/webui/assets/index-CUvpnClx.js +50 -0
  97. package/dist/webui/assets/index-CePlRZX6.css +1 -0
  98. package/dist/webui/index.html +110 -0
  99. package/docs/plugin-api.md +2 -0
  100. package/docs/webui.md +142 -0
  101. package/package.json +14 -3
  102. package/src/app.ts +286 -118
  103. package/src/core/client.ts +4 -0
  104. package/src/core/config/schema.ts +40 -0
  105. package/src/core/dispatch/command.ts +7 -1
  106. package/src/core/dispatch/router.ts +17 -1
  107. package/src/core/index.ts +3 -0
  108. package/src/core/kernel.ts +25 -1
  109. package/src/core/logging/log-buffer.ts +109 -0
  110. package/src/core/logging/logger.ts +7 -3
  111. package/src/core/plugin/command-params.ts +165 -7
  112. package/src/core/storage/session.ts +95 -0
  113. package/src/core/web/api.ts +932 -0
  114. package/src/core/web/auth.ts +245 -0
  115. package/src/core/web/config-store.ts +333 -0
  116. package/src/core/web/event-feed.ts +163 -0
  117. package/src/core/web/http.ts +340 -0
  118. package/src/core/web/index.ts +25 -0
  119. package/src/core/web/server.ts +621 -0
  120. package/src/core/web/types.ts +100 -0
package/src/app.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { resolve } from 'node:path'
2
+ import type { SnowLumaEvent } from '@snowluma/sdk'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
3
5
  import { parseArgs } from 'node:util'
6
+ import { fileURLToPath } from 'node:url'
4
7
  import { createBotClient } from './core/client.js'
5
8
  import type { AppConfig } from './core/config/schema.js'
6
9
  import {
@@ -10,15 +13,25 @@ import {
10
13
  resolveDataDir,
11
14
  } from './core/config/loader.js'
12
15
  import { Kernel } from './core/kernel.js'
16
+ import { ConfigStore } from './core/web/config-store.js'
17
+ import { EventFeed } from './core/web/event-feed.js'
18
+ import { LogBuffer } from './core/logging/log-buffer.js'
13
19
  import {
14
20
  createLogger,
21
+ defaultLogSink,
15
22
  parseLogLevel,
23
+ type LogSink,
16
24
  type Logger,
17
25
  } from './core/logging/logger.js'
18
26
  import { NpmPluginInstaller } from './core/plugin/installer.js'
19
27
  import { loadLocalPlugins } from './core/plugin/loader.js'
20
- import type { AnyPluginDefinition } from './core/plugin/types.js'
21
- import { openDatabase } from './core/storage/database.js'
28
+ import type {
29
+ AnyPluginDefinition,
30
+ PluginInstaller,
31
+ } from './core/plugin/types.js'
32
+ import { openDatabase, type Database } from './core/storage/database.js'
33
+ import { WebUiServer } from './core/web/server.js'
34
+ import type { WebUiAddress, WebUiDeps } from './core/web/types.js'
22
35
  import { builtinPlugins } from './plugins/index.js'
23
36
 
24
37
  const LOCAL_PLUGIN_DIR = 'plugins'
@@ -89,6 +102,13 @@ interface RuntimeSpec {
89
102
  interface Runtime {
90
103
  spec: RuntimeSpec
91
104
  kernel: Kernel
105
+ database: Database
106
+ installer: PluginInstaller
107
+ }
108
+
109
+ interface StaticBinding {
110
+ host: string
111
+ port: number
92
112
  }
93
113
 
94
114
  async function loadRuntimeSpec(
@@ -130,7 +150,8 @@ async function loadRuntimeSpec(
130
150
  function createRuntime(
131
151
  spec: RuntimeSpec,
132
152
  settings: RuntimeSettings,
133
- onRestart: () => void
153
+ onRestart: () => void,
154
+ onEvent: (event: SnowLumaEvent) => void
134
155
  ): Runtime {
135
156
  const { logger } = settings
136
157
  const installer = new NpmPluginInstaller({
@@ -158,9 +179,51 @@ function createRuntime(
158
179
  plugins: spec.plugins,
159
180
  installer,
160
181
  onRestart,
182
+ onEvent,
161
183
  })
162
184
 
163
- return { spec, kernel }
185
+ return { spec, kernel, database, installer }
186
+ }
187
+
188
+ function describeError(error: unknown): string {
189
+ if (error instanceof Error) {
190
+ return error.stack ?? `${error.name}: ${error.message}`
191
+ }
192
+
193
+ return String(error)
194
+ }
195
+
196
+ function credentialSignature(config: AppConfig): string {
197
+ const webui = config.webui
198
+
199
+ return [webui.username ?? '', webui.password ?? ''].join(':')
200
+ }
201
+
202
+ function resolveStaticDir(): string | null {
203
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
204
+ const candidates = [
205
+ join(scriptDir, 'webui'),
206
+ resolve(process.cwd(), 'dist/webui'),
207
+ ]
208
+
209
+ for (const candidate of candidates) {
210
+ if (existsSync(join(candidate, 'index.html'))) return candidate
211
+ }
212
+
213
+ return null
214
+ }
215
+
216
+ function readVersion(): string {
217
+ try {
218
+ const pkgPath = new URL('../package.json', import.meta.url)
219
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
220
+ version?: string
221
+ }
222
+
223
+ return pkg.version ?? 'unknown'
224
+ } catch {
225
+ return 'unknown'
226
+ }
164
227
  }
165
228
 
166
229
  async function main(): Promise<void> {
@@ -179,11 +242,22 @@ async function main(): Promise<void> {
179
242
  return
180
243
  }
181
244
 
245
+ const logs = new LogBuffer()
246
+ const events = new EventFeed()
247
+ const tapEvents = (event: SnowLumaEvent): void => {
248
+ events.add(event)
249
+ }
250
+ const sink: LogSink = (record) => {
251
+ defaultLogSink(record)
252
+ logs.push(record)
253
+ }
254
+
182
255
  const logger = createLogger({
183
256
  level: parseLogLevel(
184
257
  pick(values['log-level']) ?? process.env.BOT_LOG_LEVEL
185
258
  ),
186
259
  scope: 'app',
260
+ sink,
187
261
  })
188
262
 
189
263
  const configPath = resolveConfigPath({
@@ -202,48 +276,22 @@ async function main(): Promise<void> {
202
276
  dataDir,
203
277
  }
204
278
 
279
+ // Config file errors remain fatal: there is nothing the WebUI can restore
280
+ // from when the config cannot even be parsed.
281
+ const initialSpec = await loadRuntimeSpec(settings)
282
+
205
283
  let runtime: Runtime | null = null
284
+ let currentConfig: AppConfig = initialSpec.config
285
+ let generation = 0
286
+ let startupError: string | null = null
206
287
  let shuttingDown = false
207
288
  let restarting = false
208
289
  let restartPending = false
209
290
  let restartTimer: NodeJS.Timeout | null = null
210
-
211
- const shutdown = async (signal: string, exitCode = 0): Promise<void> => {
212
- if (shuttingDown) return
213
-
214
- shuttingDown = true
215
-
216
- if (restartTimer !== null) {
217
- clearTimeout(restartTimer)
218
- restartTimer = null
219
- }
220
-
221
- logger.info(`received ${signal}, shutting down`)
222
-
223
- try {
224
- await runtime?.kernel.stop()
225
- process.exit(exitCode)
226
- } catch (error) {
227
- logger.error('shutdown failed', error)
228
- process.exit(1)
229
- }
230
- }
231
-
232
- const requestRestart = (): void => {
233
- if (shuttingDown) return
234
-
235
- if (restarting) {
236
- restartPending = true
237
- return
238
- }
239
-
240
- if (restartTimer !== null) return
241
-
242
- restartTimer = setTimeout(() => {
243
- restartTimer = null
244
- void restartRuntime()
245
- }, 250)
246
- }
291
+ let restartTail: Promise<boolean> = Promise.resolve(true)
292
+ let exclusiveTail: Promise<unknown> = Promise.resolve()
293
+ let boundWebUi: StaticBinding | null = null
294
+ let credentials = credentialSignature(initialSpec.config)
247
295
 
248
296
  const restartDrainTimeout = (previous: Runtime | null): number => {
249
297
  const installTimeout =
@@ -252,115 +300,198 @@ async function main(): Promise<void> {
252
300
  return Math.max(installTimeout, 5_000) * 2 + 5_000
253
301
  }
254
302
 
255
- const restartRuntime = async (): Promise<void> => {
256
- if (shuttingDown || restarting) return
303
+ const restartRuntimeOnce = async (): Promise<boolean> => {
304
+ if (shuttingDown) return false
257
305
 
258
306
  restarting = true
259
307
  restartPending = false
260
308
 
261
309
  const previous = runtime
262
- let stopped = false
263
- let restarted = false
264
310
 
265
311
  logger.info('restarting runtime')
266
312
 
267
313
  try {
268
- // Stop accepting new events so no command can start another install
269
- // while the replacement runtime is being prepared.
270
314
  previous?.kernel.pause()
271
315
 
272
316
  const drainTimeout = restartDrainTimeout(previous)
273
- const drained =
274
- previous === null
275
- ? true
276
- : await previous.kernel.drain(drainTimeout)
277
317
 
278
- // Restart requests raised by drained tasks are covered by the spec
279
- // loaded below; requests that arrive later are preserved.
280
- restartPending = false
318
+ if (previous !== null) {
319
+ const drained = await previous.kernel.drain(drainTimeout)
281
320
 
282
- if (shuttingDown) return
321
+ if (!drained) {
322
+ logger.warn('runtime restart drain timed out, cancelling')
323
+ }
283
324
 
284
- let spec: RuntimeSpec
325
+ restartPending = false
285
326
 
286
- if (drained) {
287
- spec = await loadRuntimeSpec(settings)
327
+ if (shuttingDown) return false
288
328
 
289
- if (shuttingDown) return
329
+ await previous.kernel.stop()
330
+ }
290
331
 
291
- await previous?.kernel.stop()
292
- stopped = true
293
- } else {
294
- logger.warn(
295
- 'runtime restart drain timed out, cancelling pending tasks'
296
- )
332
+ if (shuttingDown) return false
297
333
 
298
- await previous?.kernel.stop()
299
- stopped = true
300
-
301
- // Kernel.stop() aborts pending tasks. Wait for their rollback
302
- // to finish before the replacement runtime touches the same
303
- // install directory.
304
- const settled =
305
- previous === null
306
- ? true
307
- : await previous.kernel.drain(drainTimeout)
308
-
309
- if (!settled) {
310
- logger.warn(
311
- 'cancelled tasks are still running after restart drain'
312
- )
313
- }
334
+ const spec = await loadRuntimeSpec(settings)
314
335
 
315
- if (shuttingDown) return
336
+ currentConfig = spec.config
316
337
 
317
- spec = await loadRuntimeSpec(settings)
318
- }
338
+ const next = createRuntime(
339
+ spec,
340
+ settings,
341
+ requestRestart,
342
+ tapEvents
343
+ )
319
344
 
320
- if (shuttingDown) return
345
+ runtime = next
346
+ await next.kernel.start()
321
347
 
322
- runtime = createRuntime(spec, settings, requestRestart)
323
- await runtime.kernel.start()
324
- restarted = true
325
- logger.info('runtime restarted')
326
- } catch (error) {
327
- logger.error('runtime restart failed', error)
348
+ generation += 1
349
+ startupError = null
328
350
 
329
- if (!stopped) {
330
- previous?.kernel.resume()
331
- return
332
- }
351
+ const signature = credentialSignature(currentConfig)
333
352
 
334
- if (previous !== null) {
335
- try {
336
- await runtime?.kernel.stop()
337
- runtime = createRuntime(
338
- previous.spec,
339
- settings,
340
- requestRestart
341
- )
342
- await runtime.kernel.start()
343
- logger.warn('restored previous runtime')
344
- return
345
- } catch (fallbackError) {
346
- logger.error(
347
- 'failed to restore previous runtime',
348
- fallbackError
349
- )
350
- }
353
+ if (signature !== credentials) {
354
+ credentials = signature
355
+ server.invalidateSessions()
351
356
  }
352
357
 
353
- await shutdown('restart failure', 1)
358
+ logger.info('runtime restarted')
359
+
360
+ return true
361
+ } catch (error) {
362
+ runtime = null
363
+ startupError = describeError(error)
364
+ logger.error('runtime restart failed', error)
365
+
366
+ return false
354
367
  } finally {
355
368
  restarting = false
356
369
 
357
- if (restarted && restartPending && !shuttingDown) {
370
+ if (restartPending && !shuttingDown) {
358
371
  restartPending = false
359
372
  requestRestart()
360
373
  }
361
374
  }
362
375
  }
363
376
 
377
+ const restartRuntime = (): Promise<boolean> => {
378
+ const run = restartTail.then(restartRuntimeOnce, restartRuntimeOnce)
379
+
380
+ restartTail = run.then(
381
+ () => true,
382
+ () => false
383
+ )
384
+
385
+ return run
386
+ }
387
+
388
+ const requestRestart = (): void => {
389
+ if (shuttingDown) return
390
+
391
+ if (restarting) {
392
+ restartPending = true
393
+ return
394
+ }
395
+
396
+ if (restartTimer !== null) return
397
+
398
+ restartTimer = setTimeout(() => {
399
+ restartTimer = null
400
+ void restartRuntime()
401
+ }, 250)
402
+ }
403
+
404
+ const store = new ConfigStore({
405
+ configPath,
406
+ getPlugins: () => runtime?.spec.plugins ?? [],
407
+ restart: () => restartRuntime(),
408
+ })
409
+
410
+ const deps: WebUiDeps = {
411
+ logger: logger.child('webui'),
412
+ paths: {
413
+ configPath,
414
+ dataDir,
415
+ pluginDir: initialSpec.pluginDir,
416
+ staticDir: resolveStaticDir(),
417
+ },
418
+ version: readVersion(),
419
+ logs,
420
+ events,
421
+ getWebUiConfig: () => currentConfig.webui,
422
+ readConfig: () => store.read(),
423
+ validateConfig: (value) => store.validate(value),
424
+ applyConfig: (value, baseHash) => store.apply(value, baseHash),
425
+ restart: () => restartRuntime(),
426
+ requestRestart,
427
+ pause: () => runtime?.kernel.pause(),
428
+ resume: () => runtime?.kernel.resume(),
429
+ getListening: () => server.address,
430
+ invalidateSessions: () => server.invalidateSessions(),
431
+ runExclusive: async <T>(task: () => Promise<T>): Promise<T> => {
432
+ const run = exclusiveTail.then(task, task)
433
+
434
+ exclusiveTail = run.then(
435
+ () => undefined,
436
+ () => undefined
437
+ )
438
+
439
+ return run
440
+ },
441
+ getRuntime: () =>
442
+ runtime === null
443
+ ? null
444
+ : {
445
+ generation,
446
+ config: runtime.spec.config,
447
+ kernel: runtime.kernel,
448
+ api: runtime.kernel.api,
449
+ database: runtime.database,
450
+ installer: runtime.installer,
451
+ plugins: runtime.spec.plugins,
452
+ },
453
+ isRestarting: () => restarting,
454
+ getStartupError: () => startupError,
455
+ getRequiresProcessRestart: () => {
456
+ const webui = currentConfig.webui
457
+
458
+ if (boundWebUi === null) return webui.enabled
459
+
460
+ if (!webui.enabled) return true
461
+
462
+ return (
463
+ webui.host !== boundWebUi.host || webui.port !== boundWebUi.port
464
+ )
465
+ },
466
+ }
467
+
468
+ const server = new WebUiServer({
469
+ config: initialSpec.config.webui,
470
+ deps,
471
+ })
472
+
473
+ const shutdown = async (signal: string, exitCode = 0): Promise<void> => {
474
+ if (shuttingDown) return
475
+
476
+ shuttingDown = true
477
+
478
+ if (restartTimer !== null) {
479
+ clearTimeout(restartTimer)
480
+ restartTimer = null
481
+ }
482
+
483
+ logger.info(`received ${signal}, shutting down`)
484
+
485
+ try {
486
+ await runtime?.kernel.stop()
487
+ await server.stop()
488
+ process.exit(exitCode)
489
+ } catch (error) {
490
+ logger.error('shutdown failed', error)
491
+ process.exit(1)
492
+ }
493
+ }
494
+
364
495
  process.once('SIGINT', () => {
365
496
  void shutdown('SIGINT')
366
497
  })
@@ -369,9 +500,46 @@ async function main(): Promise<void> {
369
500
  void shutdown('SIGTERM')
370
501
  })
371
502
 
372
- const spec = await loadRuntimeSpec(settings)
373
- runtime = createRuntime(spec, settings, requestRestart)
374
- await runtime.kernel.start()
503
+ // Start the WebUI before the kernel so it stays online when startup fails.
504
+ if (initialSpec.config.webui.enabled) {
505
+ const address: WebUiAddress | null = await server.start(
506
+ initialSpec.config.webui
507
+ )
508
+
509
+ if (address !== null) {
510
+ boundWebUi = {
511
+ host: address.host,
512
+ port: address.port,
513
+ }
514
+ }
515
+ }
516
+
517
+ try {
518
+ runtime = createRuntime(
519
+ initialSpec,
520
+ settings,
521
+ requestRestart,
522
+ tapEvents
523
+ )
524
+ await runtime.kernel.start()
525
+
526
+ generation = 1
527
+ startupError = null
528
+ credentials = credentialSignature(currentConfig)
529
+ logger.info('runtime started')
530
+ } catch (error) {
531
+ runtime = null
532
+ startupError = describeError(error)
533
+ logger.error('runtime startup failed', error)
534
+
535
+ if (boundWebUi === null) {
536
+ await shutdown('startup failure', 1)
537
+ } else {
538
+ logger.info(
539
+ 'webui stays online; fix the configuration on the config page and restart the runtime'
540
+ )
541
+ }
542
+ }
375
543
  }
376
544
 
377
545
  await main().catch((error: unknown) => {
@@ -19,6 +19,7 @@ export interface BotClientOptions {
19
19
  }
20
20
 
21
21
  export interface BotClient {
22
+ readonly isConnected: boolean
22
23
  use(middleware: EventMiddleware): () => void
23
24
  connect(): Promise<void>
24
25
  close(): void
@@ -74,6 +75,9 @@ export function createBotClient(options: BotClientOptions): BotClient {
74
75
  })
75
76
 
76
77
  return {
78
+ get isConnected() {
79
+ return client.isConnected
80
+ },
77
81
  use: (middleware) => client.use(middleware),
78
82
  connect: () => client.connect(),
79
83
  close: () => client.close(),
@@ -55,12 +55,51 @@ export const installerConfigSchema = z
55
55
  })
56
56
  .strict()
57
57
 
58
+ export const webuiConfigSchema = z
59
+ .object({
60
+ enabled: z.boolean().default(false),
61
+ host: z.string().trim().min(1).max(255).default('127.0.0.1'),
62
+ port: z.number().int().min(1).max(65535).default(8787),
63
+ username: z.string().trim().min(1).max(64).optional(),
64
+ password: z.string().min(8).max(256).optional(),
65
+ sessionTtlHours: z.number().int().min(1).max(720).default(12),
66
+ trustProxy: z.boolean().default(false),
67
+ })
68
+ .strict()
69
+ .superRefine((value, context) => {
70
+ if (!value.enabled) return
71
+
72
+ if (value.username === undefined) {
73
+ context.addIssue({
74
+ code: 'custom',
75
+ message: 'username is required when webui is enabled',
76
+ path: ['username'],
77
+ })
78
+ }
79
+
80
+ if (value.password === undefined) {
81
+ context.addIssue({
82
+ code: 'custom',
83
+ message: 'password is required when webui is enabled',
84
+ path: ['password'],
85
+ })
86
+ }
87
+ })
88
+ .default({
89
+ enabled: false,
90
+ host: '127.0.0.1',
91
+ port: 8787,
92
+ sessionTtlHours: 12,
93
+ trustProxy: false,
94
+ })
95
+
58
96
  export const appConfigSchema = z
59
97
  .object({
60
98
  url: z.url({ protocol: /^wss?$/ }).default('ws://127.0.0.1:3001/'),
61
99
  accessToken: z.string().min(1).optional(),
62
100
  roles: rolesConfigSchema,
63
101
  message: messageConfigSchema,
102
+ webui: webuiConfigSchema,
64
103
  plugins: z.record(z.string(), pluginEnvelopeSchema).default({}),
65
104
  installer: installerConfigSchema.default({
66
105
  enabled: false,
@@ -77,6 +116,7 @@ export type PluginEnvelope = z.infer<typeof pluginEnvelopeSchema>
77
116
  export type MessageConfig = z.infer<typeof messageConfigSchema>
78
117
  export type RolesConfig = z.infer<typeof rolesConfigSchema>
79
118
  export type InstallerConfig = z.infer<typeof installerConfigSchema>
119
+ export type WebUiConfig = z.infer<typeof webuiConfigSchema>
80
120
  export type AppConfig = z.infer<typeof appConfigSchema>
81
121
 
82
122
  export function formatConfigIssues(error: z.ZodError, prefix?: string): string {
@@ -89,6 +89,12 @@ function uniqueStrings(values: string[]): string[] {
89
89
  return result
90
90
  }
91
91
 
92
+ const LEADING_REPLY = /^(?:\[CQ:reply(?:,[^\]]*)?\]\s*)+/
93
+
94
+ function stripLeadingReply(raw: string): string {
95
+ return raw.replace(LEADING_REPLY, '')
96
+ }
97
+
92
98
  function groupBases(name: string, aliases: string[]): string[] {
93
99
  return uniqueStrings([
94
100
  normalizeSegment('group name', name),
@@ -258,7 +264,7 @@ export class CommandRegistry {
258
264
  }
259
265
 
260
266
  match(rawMessage: string): RegisteredCommand | null {
261
- const raw = rawMessage.trim()
267
+ const raw = stripLeadingReply(rawMessage.trim()).trim()
262
268
 
263
269
  if (!raw) return null
264
270
 
@@ -53,6 +53,7 @@ export interface RouterDeps {
53
53
  roles: RoleService
54
54
  gate: PluginGate
55
55
  logger: Logger
56
+ onEvent?: ((event: SnowLumaEvent) => void) | undefined
56
57
  }
57
58
 
58
59
  type AnyCommandHandler = CommandHandler<OneBotMessageEvent, any[]>
@@ -106,12 +107,14 @@ export class Router {
106
107
  private readonly handlers = new HandlerRegistry()
107
108
  private readonly middlewares = new MiddlewareRegistry()
108
109
  private readonly active = new Set<Promise<void>>()
110
+ private readonly onEvent: ((event: SnowLumaEvent) => void) | undefined
109
111
 
110
112
  constructor(deps: RouterDeps) {
111
113
  this.sender = deps.sender
112
114
  this.roles = deps.roles
113
115
  this.gate = deps.gate
114
116
  this.logger = deps.logger.child('router')
117
+ this.onEvent = deps.onEvent
115
118
  this.refuser = new PermissionRefuser(deps.sender, this.logger)
116
119
  }
117
120
 
@@ -122,6 +125,8 @@ export class Router {
122
125
  ) => {
123
126
  if (!this.accepting) return
124
127
 
128
+ this.emitEvent(event)
129
+
125
130
  const run = this.handleEvent(event, sdkContext, next)
126
131
  this.active.add(run)
127
132
 
@@ -132,6 +137,16 @@ export class Router {
132
137
  }
133
138
  }
134
139
 
140
+ private emitEvent(event: SnowLumaEvent): void {
141
+ if (!this.onEvent) return
142
+
143
+ try {
144
+ this.onEvent(event)
145
+ } catch (error) {
146
+ this.logger.error('event tap failed', error)
147
+ }
148
+ }
149
+
135
150
  private async handleEvent(
136
151
  event: SnowLumaEvent,
137
152
  sdkContext: SnowLumaEventContextWithQuote,
@@ -180,7 +195,8 @@ export class Router {
180
195
  try {
181
196
  const parsed =
182
197
  found.registration.paramsParser.parse(
183
- found.match.rest
198
+ found.match.rest,
199
+ event
184
200
  )
185
201
 
186
202
  args = parsed.args
package/src/core/index.ts CHANGED
@@ -6,6 +6,7 @@ export type {
6
6
  PluginEnvelope,
7
7
  PluginGroups,
8
8
  RolesConfig,
9
+ WebUiConfig,
9
10
  } from './config/schema.js'
10
11
  export type { TaskQueue } from './dispatch/task-queue.js'
11
12
  export type { Logger, LogLevel } from './logging/logger.js'
@@ -50,8 +51,10 @@ export type {
50
51
  } from './plugin/types.js'
51
52
  export type { Permission } from './security/permission.js'
52
53
  export type {
54
+ SessionListOptions,
53
55
  SessionMessage,
54
56
  SessionRole,
55
57
  SessionState,
56
58
  SessionStore,
59
+ SessionSummary,
57
60
  } from './storage/session.js'