@jsbot-tellus/core 2.2.0 → 2.4.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 (116) 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/router.d.ts +3 -0
  14. package/dist/core/dispatch/router.d.ts.map +1 -1
  15. package/dist/core/dispatch/router.js +13 -0
  16. package/dist/core/dispatch/router.js.map +1 -1
  17. package/dist/core/index.d.ts +2 -2
  18. package/dist/core/index.d.ts.map +1 -1
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/kernel.d.ts +8 -0
  21. package/dist/core/kernel.d.ts.map +1 -1
  22. package/dist/core/kernel.js +17 -0
  23. package/dist/core/kernel.js.map +1 -1
  24. package/dist/core/logging/log-buffer.d.ts +27 -0
  25. package/dist/core/logging/log-buffer.d.ts.map +1 -0
  26. package/dist/core/logging/log-buffer.js +71 -0
  27. package/dist/core/logging/log-buffer.js.map +1 -0
  28. package/dist/core/logging/logger.d.ts +3 -0
  29. package/dist/core/logging/logger.d.ts.map +1 -1
  30. package/dist/core/logging/logger.js +6 -3
  31. package/dist/core/logging/logger.js.map +1 -1
  32. package/dist/core/plugin/command-params.d.ts +2 -2
  33. package/dist/core/plugin/command-params.d.ts.map +1 -1
  34. package/dist/core/plugin/command-params.js +35 -0
  35. package/dist/core/plugin/command-params.js.map +1 -1
  36. package/dist/core/storage/session.d.ts +15 -0
  37. package/dist/core/storage/session.d.ts.map +1 -1
  38. package/dist/core/storage/session.js +55 -0
  39. package/dist/core/storage/session.js.map +1 -1
  40. package/dist/core/web/api.d.ts +16 -0
  41. package/dist/core/web/api.d.ts.map +1 -0
  42. package/dist/core/web/api.js +617 -0
  43. package/dist/core/web/api.js.map +1 -0
  44. package/dist/core/web/auth.d.ts +43 -0
  45. package/dist/core/web/auth.d.ts.map +1 -0
  46. package/dist/core/web/auth.js +161 -0
  47. package/dist/core/web/auth.js.map +1 -0
  48. package/dist/core/web/config-store.d.ts +30 -0
  49. package/dist/core/web/config-store.d.ts.map +1 -0
  50. package/dist/core/web/config-store.js +216 -0
  51. package/dist/core/web/config-store.js.map +1 -0
  52. package/dist/core/web/event-feed.d.ts +26 -0
  53. package/dist/core/web/event-feed.d.ts.map +1 -0
  54. package/dist/core/web/event-feed.js +107 -0
  55. package/dist/core/web/event-feed.js.map +1 -0
  56. package/dist/core/web/http.d.ts +39 -0
  57. package/dist/core/web/http.d.ts.map +1 -0
  58. package/dist/core/web/http.js +233 -0
  59. package/dist/core/web/http.js.map +1 -0
  60. package/dist/core/web/index.d.ts +11 -0
  61. package/dist/core/web/index.d.ts.map +1 -0
  62. package/dist/core/web/index.js +6 -0
  63. package/dist/core/web/index.js.map +1 -0
  64. package/dist/core/web/server.d.ts +30 -0
  65. package/dist/core/web/server.d.ts.map +1 -0
  66. package/dist/core/web/server.js +437 -0
  67. package/dist/core/web/server.js.map +1 -0
  68. package/dist/core/web/types.d.ts +84 -0
  69. package/dist/core/web/types.d.ts.map +1 -0
  70. package/dist/core/web/types.js +2 -0
  71. package/dist/core/web/types.js.map +1 -0
  72. package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
  73. package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
  74. package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
  75. package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
  76. package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
  77. package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
  78. package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
  79. package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
  80. package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
  81. package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
  82. package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
  83. package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
  84. package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
  85. package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
  86. package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
  87. package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
  88. package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
  89. package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
  90. package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
  91. package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
  92. package/dist/webui/assets/api-CNzxhTWe.js +3 -0
  93. package/dist/webui/assets/index-CUvpnClx.js +50 -0
  94. package/dist/webui/assets/index-CePlRZX6.css +1 -0
  95. package/dist/webui/index.html +110 -0
  96. package/docs/plugin-api.md +1 -0
  97. package/docs/webui.md +142 -0
  98. package/package.json +14 -3
  99. package/src/app.ts +286 -118
  100. package/src/core/client.ts +4 -0
  101. package/src/core/config/schema.ts +40 -0
  102. package/src/core/dispatch/router.ts +15 -0
  103. package/src/core/index.ts +3 -0
  104. package/src/core/kernel.ts +25 -1
  105. package/src/core/logging/log-buffer.ts +109 -0
  106. package/src/core/logging/logger.ts +7 -3
  107. package/src/core/plugin/command-params.ts +48 -2
  108. package/src/core/storage/session.ts +95 -0
  109. package/src/core/web/api.ts +932 -0
  110. package/src/core/web/auth.ts +245 -0
  111. package/src/core/web/config-store.ts +333 -0
  112. package/src/core/web/event-feed.ts +163 -0
  113. package/src/core/web/http.ts +340 -0
  114. package/src/core/web/index.ts +25 -0
  115. package/src/core/web/server.ts +621 -0
  116. package/src/core/web/types.ts +100 -0
@@ -0,0 +1,163 @@
1
+ import type { SnowLumaEvent } from '@snowluma/sdk'
2
+
3
+ export type FeedEventType = 'message' | 'notice' | 'request' | 'meta' | 'other'
4
+
5
+ export interface FeedEvent {
6
+ id: number
7
+ time: number
8
+ type: FeedEventType
9
+ summary: string
10
+ event: SnowLumaEvent
11
+ }
12
+
13
+ export interface FeedFilter {
14
+ type?: FeedEventType | undefined
15
+ limit?: number | undefined
16
+ }
17
+
18
+ function recordOf(event: SnowLumaEvent): Record<string, unknown> {
19
+ return event as unknown as Record<string, unknown>
20
+ }
21
+
22
+ function eventTypeOf(event: SnowLumaEvent): FeedEventType {
23
+ const postType = recordOf(event).post_type
24
+
25
+ switch (postType) {
26
+ case 'message':
27
+ return 'message'
28
+ case 'notice':
29
+ return 'notice'
30
+ case 'request':
31
+ return 'request'
32
+ case 'meta_event':
33
+ return 'meta'
34
+ default:
35
+ return 'other'
36
+ }
37
+ }
38
+
39
+ function readString(
40
+ value: Record<string, unknown>,
41
+ key: string
42
+ ): string | undefined {
43
+ const item = value[key]
44
+
45
+ return typeof item === 'string' ? item : undefined
46
+ }
47
+
48
+ function readNumber(
49
+ value: Record<string, unknown>,
50
+ key: string
51
+ ): number | undefined {
52
+ const item = value[key]
53
+
54
+ return typeof item === 'number' ? item : undefined
55
+ }
56
+
57
+ function truncate(value: string, max = 200): string {
58
+ return value.length > max ? `${value.slice(0, max)}…` : value
59
+ }
60
+
61
+ function summarize(event: SnowLumaEvent): string {
62
+ const value = recordOf(event)
63
+ const type = eventTypeOf(event)
64
+
65
+ if (type === 'message') {
66
+ const sender = value.sender
67
+
68
+ if (typeof sender === 'object' && sender !== null) {
69
+ const senderRecord = sender as Record<string, unknown>
70
+ const name =
71
+ readString(senderRecord, 'card') ??
72
+ readString(senderRecord, 'nickname') ??
73
+ String(readNumber(value, 'user_id') ?? 'unknown')
74
+ const raw = readString(value, 'raw_message') ?? ''
75
+
76
+ return truncate(`${name}: ${raw}`)
77
+ }
78
+
79
+ return truncate(readString(value, 'raw_message') ?? 'message')
80
+ }
81
+
82
+ if (type === 'notice') {
83
+ return truncate(
84
+ `notice ${readString(value, 'notice_type') ?? 'unknown'}`
85
+ )
86
+ }
87
+
88
+ if (type === 'request') {
89
+ return truncate(
90
+ `request ${readString(value, 'request_type') ?? 'unknown'}`
91
+ )
92
+ }
93
+
94
+ if (type === 'meta') {
95
+ return truncate(
96
+ `meta ${readString(value, 'meta_event_type') ?? 'unknown'}`
97
+ )
98
+ }
99
+
100
+ return truncate(`event ${String(value.post_type ?? 'unknown')}`)
101
+ }
102
+
103
+ export class EventFeed {
104
+ private readonly entries: FeedEvent[] = []
105
+ private readonly listeners = new Set<(entry: FeedEvent) => void>()
106
+ private nextId = 1
107
+
108
+ constructor(private readonly capacity = 200) {}
109
+
110
+ get size(): number {
111
+ return this.entries.length
112
+ }
113
+
114
+ add(event: SnowLumaEvent): void {
115
+ const entry: FeedEvent = {
116
+ id: this.nextId,
117
+ time: Date.now(),
118
+ type: eventTypeOf(event),
119
+ summary: summarize(event),
120
+ event,
121
+ }
122
+
123
+ this.nextId += 1
124
+ this.entries.push(entry)
125
+
126
+ if (this.entries.length > this.capacity) {
127
+ this.entries.splice(0, this.entries.length - this.capacity)
128
+ }
129
+
130
+ for (const listener of this.listeners) {
131
+ try {
132
+ listener(entry)
133
+ } catch {
134
+ // Event consumers must never break dispatch.
135
+ }
136
+ }
137
+ }
138
+
139
+ list(filter: FeedFilter = {}): FeedEvent[] {
140
+ const matched = this.entries.filter(
141
+ (entry) => filter.type === undefined || entry.type === filter.type
142
+ )
143
+ const limit = filter.limit
144
+
145
+ if (limit === undefined || limit < 0 || limit >= matched.length) {
146
+ return matched
147
+ }
148
+
149
+ return matched.slice(-limit)
150
+ }
151
+
152
+ clear(): void {
153
+ this.entries.length = 0
154
+ }
155
+
156
+ subscribe(listener: (entry: FeedEvent) => void): () => void {
157
+ this.listeners.add(listener)
158
+
159
+ return () => {
160
+ this.listeners.delete(listener)
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,340 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+
3
+ export const CSRF_HEADER = 'x-requested-with'
4
+ export const CSRF_VALUE = 'jsbot-webui'
5
+
6
+ export interface CookieOptions {
7
+ maxAgeSeconds?: number | undefined
8
+ secure?: boolean | undefined
9
+ }
10
+
11
+ interface SendHeaders {
12
+ [key: string]: string | undefined
13
+ }
14
+
15
+ export class HttpError extends Error {
16
+ readonly status: number
17
+ readonly details: unknown
18
+
19
+ constructor(status: number, message: string, details?: unknown) {
20
+ super(message)
21
+ this.name = 'HttpError'
22
+ this.status = status
23
+ this.details = details
24
+ }
25
+ }
26
+
27
+ export function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
29
+ }
30
+
31
+ export async function readJsonBody(
32
+ req: IncomingMessage,
33
+ limitBytes = 1_048_576
34
+ ): Promise<unknown> {
35
+ const chunks: Buffer[] = []
36
+ let size = 0
37
+
38
+ for await (const chunk of req) {
39
+ const buffer = chunk as Buffer
40
+
41
+ size += buffer.length
42
+
43
+ if (size > limitBytes) {
44
+ throw new HttpError(413, 'request body too large')
45
+ }
46
+
47
+ chunks.push(buffer)
48
+ }
49
+
50
+ const text = Buffer.concat(chunks).toString('utf8').trim()
51
+
52
+ if (text.length === 0) return {}
53
+
54
+ try {
55
+ return JSON.parse(text) as unknown
56
+ } catch {
57
+ throw new HttpError(400, 'request body must be valid JSON')
58
+ }
59
+ }
60
+
61
+ export interface URLSearchParamsLike {
62
+ get(name: string): string | null
63
+ }
64
+
65
+ export function sendData(
66
+ res: ServerResponse,
67
+ status: number,
68
+ data: unknown,
69
+ headers: SendHeaders = {}
70
+ ): void {
71
+ sendJson(res, status, { data }, headers)
72
+ }
73
+
74
+ export function sendJson(
75
+ res: ServerResponse,
76
+ status: number,
77
+ data: unknown,
78
+ headers: SendHeaders = {}
79
+ ): void {
80
+ res.writeHead(status, {
81
+ 'content-type': 'application/json; charset=utf-8',
82
+ 'cache-control': 'no-store',
83
+ ...headers,
84
+ })
85
+ res.end(safeStringify(data))
86
+ }
87
+
88
+ export function sendError(
89
+ res: ServerResponse,
90
+ status: number,
91
+ message: string,
92
+ details?: unknown,
93
+ headers: SendHeaders = {}
94
+ ): void {
95
+ sendJson(
96
+ res,
97
+ status,
98
+ { error: details === undefined ? { message } : { message, details } },
99
+ headers
100
+ )
101
+ }
102
+
103
+ function detailsOf(error: unknown): unknown {
104
+ if (error instanceof HttpError) return error.details
105
+
106
+ return undefined
107
+ }
108
+
109
+ export function respondWithError(res: ServerResponse, error: unknown): void {
110
+ if (error instanceof HttpError) {
111
+ if (!res.headersSent) {
112
+ sendError(res, error.status, error.message, detailsOf(error))
113
+ return
114
+ }
115
+ } else if (!res.headersSent) {
116
+ sendError(res, 500, 'internal server error')
117
+ return
118
+ }
119
+
120
+ try {
121
+ res.end()
122
+ } catch {
123
+ // Socket already destroyed.
124
+ }
125
+ }
126
+
127
+ export function parseCookies(
128
+ header: string | undefined
129
+ ): Record<string, string> {
130
+ const result: Record<string, string> = {}
131
+
132
+ if (!header) return result
133
+
134
+ for (const part of header.split(';')) {
135
+ const separator = part.indexOf('=')
136
+
137
+ if (separator <= 0) continue
138
+
139
+ const key = part.slice(0, separator).trim()
140
+ const value = part.slice(separator + 1).trim()
141
+
142
+ if (key.length === 0) continue
143
+
144
+ try {
145
+ result[key] = decodeURIComponent(value)
146
+ } catch {
147
+ result[key] = value
148
+ }
149
+ }
150
+
151
+ return result
152
+ }
153
+
154
+ export function serializeCookie(
155
+ name: string,
156
+ value: string | null,
157
+ options: CookieOptions = {}
158
+ ): string {
159
+ const parts = [`${name}=`]
160
+
161
+ if (value === null) {
162
+ parts.push('; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT')
163
+ } else {
164
+ parts.push(value)
165
+
166
+ if (options.maxAgeSeconds !== undefined) {
167
+ parts.push(
168
+ `; Max-Age=${Math.max(Math.trunc(options.maxAgeSeconds), 0)}`
169
+ )
170
+ }
171
+ }
172
+
173
+ parts.push('; Path=/; HttpOnly; SameSite=Strict')
174
+
175
+ if (options.secure === true) parts.push('; Secure')
176
+
177
+ return parts.join('')
178
+ }
179
+
180
+ export function clearCookie(name: string): string {
181
+ return serializeCookie(name, null)
182
+ }
183
+
184
+ function forwardedHost(
185
+ req: IncomingMessage,
186
+ trustProxy: boolean
187
+ ): string | undefined {
188
+ if (!trustProxy) return undefined
189
+
190
+ const value = req.headers['x-forwarded-host']
191
+
192
+ if (typeof value !== 'string' || value.length === 0) return undefined
193
+
194
+ return value.split(',')[0]?.trim()
195
+ }
196
+
197
+ export function clientIp(req: IncomingMessage, trustProxy: boolean): string {
198
+ if (trustProxy) {
199
+ const forwarded = req.headers['x-forwarded-for']
200
+
201
+ if (typeof forwarded === 'string' && forwarded.length > 0) {
202
+ const first = forwarded.split(',')[0]?.trim()
203
+
204
+ if (first) return first
205
+ }
206
+ }
207
+
208
+ const address = req.socket.remoteAddress ?? 'unknown'
209
+
210
+ return address.startsWith('::ffff:') ? address.slice(7) : address
211
+ }
212
+
213
+ export function isLoopbackHost(host: string): boolean {
214
+ try {
215
+ const url = new URL(`http://${host}`)
216
+
217
+ return (
218
+ url.hostname === '127.0.0.1' ||
219
+ url.hostname === '[::1]' ||
220
+ url.hostname === '::1' ||
221
+ url.hostname === 'localhost'
222
+ )
223
+ } catch {
224
+ return false
225
+ }
226
+ }
227
+
228
+ export function sameOrigin(req: IncomingMessage, trustProxy: boolean): boolean {
229
+ const origin = req.headers.origin
230
+
231
+ if (typeof origin !== 'string') return true
232
+
233
+ if (origin.length === 0 || origin === 'null') return false
234
+
235
+ try {
236
+ const originUrl = new URL(origin)
237
+ const host = forwardedHost(req, trustProxy) ?? req.headers.host
238
+
239
+ if (host === undefined) return false
240
+
241
+ return originUrl.host.toLowerCase() === host.trim().toLowerCase()
242
+ } catch {
243
+ return false
244
+ }
245
+ }
246
+
247
+ export function assertSameOrigin(
248
+ req: IncomingMessage,
249
+ trustProxy: boolean
250
+ ): void {
251
+ if (sameOrigin(req, trustProxy)) return
252
+
253
+ throw new HttpError(403, 'cross-origin request rejected')
254
+ }
255
+
256
+ export function csrfHeader(req: IncomingMessage): string | undefined {
257
+ const value = req.headers[CSRF_HEADER]
258
+
259
+ return typeof value === 'string' ? value : undefined
260
+ }
261
+
262
+ export function assertCsrf(req: IncomingMessage): void {
263
+ const value = csrfHeader(req)
264
+
265
+ if (value?.toLowerCase() === CSRF_VALUE) return
266
+
267
+ throw new HttpError(403, `missing ${CSRF_HEADER}: ${CSRF_VALUE} header`)
268
+ }
269
+
270
+ export function safeStringify(value: unknown): string {
271
+ const seen = new WeakSet<object>()
272
+
273
+ return JSON.stringify(value, (_key, item: unknown) => {
274
+ if (typeof item === 'bigint') return item.toString()
275
+
276
+ if (typeof item !== 'object' || item === null) return item
277
+
278
+ if (seen.has(item)) return '[Circular]'
279
+
280
+ seen.add(item)
281
+
282
+ return item
283
+ })
284
+ }
285
+
286
+ const MIME_TYPES: Record<string, string> = {
287
+ '.css': 'text/css; charset=utf-8',
288
+ '.gif': 'image/gif',
289
+ '.html': 'text/html; charset=utf-8',
290
+ '.ico': 'image/x-icon',
291
+ '.jpeg': 'image/jpeg',
292
+ '.jpg': 'image/jpeg',
293
+ '.js': 'text/javascript; charset=utf-8',
294
+ '.json': 'application/json; charset=utf-8',
295
+ '.map': 'application/json',
296
+ '.mjs': 'text/javascript; charset=utf-8',
297
+ '.png': 'image/png',
298
+ '.svg': 'image/svg+xml',
299
+ '.txt': 'text/plain; charset=utf-8',
300
+ '.webp': 'image/webp',
301
+ '.woff': 'font/woff',
302
+ '.woff2': 'font/woff2',
303
+ }
304
+
305
+ export function mimeType(filePath: string): string {
306
+ const separator = filePath.lastIndexOf('.')
307
+
308
+ if (separator < 0) return 'application/octet-stream'
309
+
310
+ return (
311
+ MIME_TYPES[filePath.slice(separator).toLowerCase()] ??
312
+ 'application/octet-stream'
313
+ )
314
+ }
315
+
316
+ export function isImmutableAsset(pathname: string): boolean {
317
+ return pathname.startsWith('/assets/')
318
+ }
319
+
320
+ export function withTimeout<T>(
321
+ promise: Promise<T>,
322
+ timeoutMs: number
323
+ ): Promise<T> {
324
+ return new Promise<T>((resolve, reject) => {
325
+ const timer = setTimeout(() => {
326
+ reject(new Error(`timeout after ${timeoutMs}ms`))
327
+ }, timeoutMs)
328
+
329
+ promise.then(
330
+ (value) => {
331
+ clearTimeout(timer)
332
+ resolve(value)
333
+ },
334
+ (error) => {
335
+ clearTimeout(timer)
336
+ reject(error)
337
+ }
338
+ )
339
+ })
340
+ }
@@ -0,0 +1,25 @@
1
+ export type { FeedEvent, FeedEventType, FeedFilter } from './event-feed.js'
2
+ export { EventFeed } from './event-feed.js'
3
+ export type { AuthSession } from './auth.js'
4
+ export { AuthManager } from './auth.js'
5
+ export type { MaskedConfig } from './config-store.js'
6
+ export {
7
+ ConfigStore,
8
+ ConfigStoreError,
9
+ maskConfig,
10
+ mergeSecrets,
11
+ } from './config-store.js'
12
+ export type {
13
+ ConfigApplyResult,
14
+ ConfigDocument,
15
+ ConfigValidationIssue,
16
+ ConfigValidationResult,
17
+ WebUiAddress,
18
+ WebUiDeps,
19
+ WebUiPaths,
20
+ WebUiRuntime,
21
+ WebUiServerOptions,
22
+ } from './types.js'
23
+ export type { WebUiConfig } from '../config/schema.js'
24
+ export { WebUiServer } from './server.js'
25
+ export { CSRF_HEADER, CSRF_VALUE, HttpError } from './http.js'