koishi-plugin-chat-patch 5.0.3 → 5.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 (32) hide show
  1. package/client/web/dist/assets/Chat-B448YOeN.js +28 -0
  2. package/client/web/dist/assets/{MsgBody-LNGNGtE3.js → MsgBody-DDacjWJ9.js} +1 -1
  3. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-B1dWyCB0.js +67 -0
  4. package/client/web/dist/assets/{index-CwyyQN13.js → index-BlLut5xn.js} +121 -129
  5. package/client/web/dist/assets/index-fW_zcub3.css +1 -0
  6. package/client/web/dist/assets/pinyin-Dsq98249.js +157657 -0
  7. package/client/web/dist/index.html +2 -2
  8. package/client/web/dist/notice_local.json +1 -0
  9. package/client/web/package-lock.json +49 -0
  10. package/client/web/package.json +1 -0
  11. package/client/web/public/notice_local.json +1 -0
  12. package/client/web/src/components/EmojiFace.vue +0 -6
  13. package/client/web/src/components/FacePan.vue +0 -12
  14. package/client/web/src/components/MsgBody.vue +3 -4
  15. package/client/web/src/components/SettingsTab.vue +133 -0
  16. package/client/web/src/function/connect.ts +42 -21
  17. package/client/web/src/function/model/emoji.ts +7 -67
  18. package/client/web/src/function/msg.ts +74 -23
  19. package/client/web/src/function/satori-model.ts +4 -3
  20. package/client/web/src/function/utils/appUtil.ts +1 -4
  21. package/client/web/src/function/utils/msgUtil.ts +121 -87
  22. package/client/web/src/function/utils/pinyin.ts +25 -65
  23. package/client/web/src/function/utils/sessionUtil.ts +107 -9
  24. package/client/web/src/pages/Friends.vue +49 -13
  25. package/client/web/src/pages/Messages.vue +45 -14
  26. package/client/web/src/pages/Options.vue +4 -4
  27. package/client/web/src/pages/options/OptDev.vue +1 -14
  28. package/package.json +1 -1
  29. package/src/web.ts +712 -712
  30. package/client/web/dist/assets/Chat-CXjwWCiV.js +0 -28
  31. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BK5okBfM.js +0 -67
  32. package/client/web/dist/assets/index-BgOX_tNa.css +0 -1
package/src/web.ts CHANGED
@@ -1,712 +1,712 @@
1
- import { Context } from 'koishi'
2
- import {} from '@koishijs/plugin-server'
3
- import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
4
- import { createHash } from 'node:crypto'
5
- import path from 'node:path'
6
- import { pathToFileURL } from 'node:url'
7
- import send from 'koa-send'
8
- import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
9
- import FileType from 'file-type'
10
-
11
- import { Config } from './config'
12
- import { ContactCacheService } from './cache'
13
- import { ChatDatabase } from './database'
14
- import { PluginLogger } from './logger'
15
- import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
16
-
17
- interface ViteConsoleServer {
18
- config?: {
19
- server?: {
20
- preTransformRequests?: boolean
21
- }
22
- }
23
- transformIndexHtml(url: string, html: string, originalUrl?: string): Promise<string>
24
- }
25
-
26
- interface ViteConsoleLike {
27
- vite?: ViteConsoleServer
28
- }
29
-
30
- export function registerWeb(
31
- ctx: Context,
32
- config: Config,
33
- database: ChatDatabase,
34
- contactCache: ContactCacheService,
35
- logger: PluginLogger,
36
- ) {
37
- const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
38
- const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
39
- const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
40
- const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
41
- const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
42
-
43
- type WebContext = ParameterizedContext<DefaultState, DefaultContext>
44
- const cacheType = (type: string) => type === 'user' ? 'friend' : type
45
-
46
- const getVite = (): ViteConsoleServer | undefined => {
47
- return (ctx.console as unknown as ViteConsoleLike).vite
48
- }
49
-
50
- const viteFsUrl = (target: string): string => {
51
- return `/vite/@fs/${path.resolve(target).split(path.sep).join('/')}`
52
- }
53
-
54
- const transformWebIndex = async (vite: ViteConsoleServer, requestPath: string) => {
55
- // 源码由 iframe 按需加载,避免 Koishi Vite 启动时提前转换整个 web 应用
56
- if (vite.config?.server) vite.config.server.preTransformRequests = false
57
- const mainFsUrl = `/@fs/${path.resolve(webSource, 'main.ts').split(path.sep).join('/')}`
58
- const html = (await fs.readFile(webIndex, 'utf8')).replace(
59
- 'src="/src/main.ts"',
60
- `src="${mainFsUrl}"`,
61
- )
62
- let transformed = await vite.transformIndexHtml('/chat-patch/web/index.html', html, requestPath)
63
- transformed = transformed
64
- .replace(/\/vite\/bcui/g, viteFsUrl(path.join(webPublic, 'bcui')))
65
- .replace(/\/vite\/css/g, viteFsUrl(path.join(webPublic, 'css')))
66
- return transformed
67
- }
68
-
69
- const servePublicFile = async (koa: WebContext, prefix: string) => {
70
- const relative = (koa.params?.[0] ?? '').replace(/^\/+/, '')
71
- const fullPath = path.resolve(webPublic, prefix, relative)
72
- if (!fullPath.startsWith(webPublic + path.sep)) {
73
- koa.status = 403
74
- koa.body = ''
75
- return
76
- }
77
- if (existsSync(fullPath) && statSync(fullPath).isFile()) {
78
- await send(koa, path.relative(webPublic, fullPath), { root: webPublic })
79
- return
80
- }
81
- koa.status = 404
82
- koa.body = ''
83
- }
84
-
85
- // web 应用里仍有 /img 这类运行时绝对路径,开发模式直接由 Koishi 服务托管
86
- const registerDevPublic = (pattern: string, prefix: string) => {
87
- ctx.server.get(pattern, async (koa: WebContext, next: () => Promise<void>) => {
88
- if (!getVite()) return next()
89
- await servePublicFile(koa, prefix)
90
- })
91
- }
92
-
93
- registerDevPublic('/img(.*)', 'img')
94
-
95
- const toExtension = (value: string): string => {
96
- const ext = value.startsWith('.') ? value : `.${value}`
97
- return ext.toLowerCase()
98
- }
99
-
100
- const normalizeGroupId = (value: string): string => {
101
- const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
102
- const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
103
- return wrapped ? wrapped[1] : raw || value
104
- }
105
-
106
- const historyChannelCandidates = (channelId: string): string[] => {
107
- const raw = normalizeGroupId(channelId)
108
- const candidates = [channelId]
109
- if (raw && raw !== channelId) candidates.push(raw)
110
- const id = raw || channelId
111
- if (!/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) {
112
- candidates.push(`group:${id}`, `private:${id}`)
113
- }
114
- if (id) {
115
- candidates.push(` [_${id}_] `, `_${id}_`, `[${id}]`, `group:${id}`)
116
- }
117
- return [...new Set(candidates)]
118
- }
119
-
120
- const mimeToExt: Record<string, string> = {
121
- 'audio/mpeg': '.mp3',
122
- 'audio/mp3': '.mp3',
123
- 'audio/mp4': '.m4a',
124
- 'audio/x-m4a': '.m4a',
125
- 'audio/wav': '.wav',
126
- 'audio/x-wav': '.wav',
127
- 'audio/webm': '.webm',
128
- 'audio/ogg': '.ogg',
129
- 'audio/aac': '.aac',
130
- 'audio/flac': '.flac',
131
- 'image/jpeg': '.jpg',
132
- 'image/png': '.png',
133
- 'image/gif': '.gif',
134
- 'image/webp': '.webp',
135
- 'video/mp4': '.mp4',
136
- 'video/webm': '.webm',
137
- 'video/quicktime': '.mov',
138
- 'video/x-msvideo': '.avi',
139
- 'video/x-matroska': '.mkv',
140
- 'video/x-flv': '.flv',
141
- 'video/3gpp': '.3gp',
142
- 'video/ogg': '.ogv',
143
- 'video/mp2t': '.ts',
144
- }
145
- const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
146
-
147
- ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
148
- const requestBody = (koa.request as unknown as { body?: unknown }).body
149
- const body = (requestBody ?? {}) as Record<string, unknown>
150
- let source = String(body.dataUrl ?? body.data ?? '')
151
- let name = String(body.name ?? '')
152
- let mime = ''
153
- let buffer: Buffer | null = null
154
- if (source) {
155
- if (source.startsWith('base64://')) source = source.slice(9)
156
- const comma = source.indexOf('base64,')
157
- const base64 = comma >= 0 ? source.slice(comma + 7) : source
158
- const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
159
- try {
160
- buffer = Buffer.from(normalizedBase64, 'base64')
161
- } catch {
162
- koa.status = 400
163
- koa.body = { error: 'invalid media data' }
164
- return
165
- }
166
- mime = source.startsWith('data:')
167
- ? source.slice(5, source.indexOf(';')).toLowerCase()
168
- : ''
169
- } else {
170
- const chunks: Buffer[] = []
171
- for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
172
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
173
- }
174
- buffer = Buffer.concat(chunks)
175
- name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
176
- try {
177
- name = decodeURIComponent(name)
178
- } catch {
179
- // 保持原始名称即可
180
- }
181
- mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
182
- }
183
- if (!buffer || !buffer.length) {
184
- koa.status = 400
185
- koa.body = { error: 'missing media data' }
186
- return
187
- }
188
- if (buffer.length > MAX_UPLOAD_BYTES) {
189
- koa.status = 413
190
- koa.body = { error: 'media file too large' }
191
- return
192
- }
193
- if (!buffer.length) {
194
- koa.status = 400
195
- koa.body = { error: 'invalid media data' }
196
- return
197
- }
198
-
199
- await fs.mkdir(uploadDir, { recursive: true })
200
- let detected: { ext?: string } | undefined
201
- try {
202
- const result = await FileType.fromBuffer(buffer)
203
- detected = result ?? undefined
204
- } catch {
205
- // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
206
- detected = undefined
207
- }
208
- const mimeExt = mimeToExt[mime] || ''
209
- const nameExt = path.extname(name).toLowerCase()
210
- const ext = detected?.ext
211
- ? toExtension(detected.ext)
212
- : mimeExt || nameExt || '.bin'
213
- const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
214
- const filePath = path.join(uploadDir, filename)
215
- if (!existsSync(filePath)) {
216
- await fs.writeFile(filePath, buffer)
217
- }
218
- koa.body = {
219
- path: pathToFileURL(filePath).href,
220
- localPath: filePath,
221
- }
222
- })
223
-
224
- ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
225
- const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
226
- if (!fileName || fileName === '.' || fileName === '..') {
227
- koa.status = 400
228
- koa.body = { error: 'missing media file' }
229
- return
230
- }
231
- const filePath = path.resolve(uploadDir, fileName)
232
- const root = path.resolve(uploadDir)
233
- if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
234
- koa.status = 400
235
- koa.body = { error: 'invalid media file' }
236
- return
237
- }
238
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
239
- koa.status = 404
240
- koa.body = { error: 'media file not found' }
241
- return
242
- }
243
- const size = statSync(filePath).size
244
- koa.set('Accept-Ranges', 'bytes')
245
- if (fileName.toLowerCase().endsWith('.webm')) {
246
- koa.type = 'audio/webm'
247
- } else {
248
- koa.type = fileName
249
- }
250
- const range = koa.headers.range
251
- if (range) {
252
- const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
253
- if (!match) {
254
- koa.status = 416
255
- koa.set('Content-Range', `bytes */${size}`)
256
- koa.body = ''
257
- return
258
- }
259
- let start = 0
260
- let end = size - 1
261
- if (match[1] === '' && match[2]) {
262
- start = Math.max(0, size - Number(match[2]))
263
- } else if (match[1]) {
264
- start = Number(match[1])
265
- end = match[2] ? Number(match[2]) : end
266
- }
267
- if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
268
- koa.status = 416
269
- koa.set('Content-Range', `bytes */${size}`)
270
- koa.body = ''
271
- return
272
- }
273
- if (end >= size) end = size - 1
274
- if (end < start) end = start
275
- koa.status = 206
276
- koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
277
- koa.set('Content-Length', String(end - start + 1))
278
- koa.body = createReadStream(filePath, { start, end })
279
- return
280
- }
281
- koa.set('Content-Length', String(size))
282
- koa.body = createReadStream(filePath)
283
- })
284
-
285
- ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
286
- const entries = await database.getAllContacts()
287
- const botMap = new Map<string, {
288
- platform: string
289
- selfId: string
290
- groups: ContactCacheItem[]
291
- friends: ContactCacheItem[]
292
- }>()
293
- for (const entry of entries) {
294
- const key = JSON.stringify([entry.platform, entry.selfId])
295
- const bot = botMap.get(key) ?? {
296
- platform: entry.platform,
297
- selfId: entry.selfId,
298
- groups: [],
299
- friends: [],
300
- }
301
- if (entry.type === 'group') bot.groups = entry.contacts
302
- if (entry.type === 'friend') bot.friends = entry.contacts
303
- botMap.set(key, bot)
304
- }
305
- koa.body = { bots: [...botMap.values()] }
306
- })
307
-
308
- ctx.server.get(`${config.basePath}/api/history`, async (koa) => {
309
- const platform = String(koa.query.platform ?? '')
310
- const selfId = String(koa.query.selfId ?? '')
311
- const channelId = String(koa.query.channelId ?? '')
312
- if (!platform || !selfId || !channelId) {
313
- koa.status = 400
314
- koa.body = { error: 'missing history params' }
315
- return
316
- }
317
- const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
318
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
319
- const beforeTime = Number(koa.query.beforeTime ?? 0)
320
- const queryMessages = async (cid: string) => {
321
- return Number.isFinite(beforeTime) && beforeTime > 0
322
- ? database.listMessagesBefore(platform, selfId, cid, beforeTime, limit)
323
- : database.listMessages(platform, selfId, cid, limit)
324
- }
325
- let messages: Awaited<ReturnType<typeof queryMessages>> = []
326
- for (const candidate of historyChannelCandidates(channelId)) {
327
- messages = await queryMessages(candidate)
328
- if (messages.length) break
329
- }
330
- let selfMessages: SelfMessageRecord[] = []
331
- const querySelfMessages = async (cid: string): Promise<SelfMessageRecord[]> => {
332
- return Number.isFinite(beforeTime) && beforeTime > 0
333
- ? database.listSelfMessagesBefore(platform, selfId, cid, beforeTime, limit)
334
- : database.listSelfMessages(platform, selfId, cid, limit)
335
- }
336
- for (const candidate of historyChannelCandidates(channelId)) {
337
- selfMessages = await querySelfMessages(candidate)
338
- if (selfMessages.length) break
339
- }
340
- // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
341
- messages.sort((a, b) => {
342
- const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
343
- const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
344
- return timeA - timeB
345
- })
346
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
347
- koa.body = { messages, selfMessages }
348
- })
349
-
350
- ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
351
- const platform = String(koa.query.platform ?? '')
352
- const selfId = String(koa.query.selfId ?? '')
353
- const channelId = String(koa.query.channelId ?? '')
354
- if (!platform || !selfId || !channelId) {
355
- koa.status = 400
356
- koa.body = { error: 'missing self-message params' }
357
- return
358
- }
359
- const parsedLimit = Number(koa.query.limit ?? config.historyPageSize)
360
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.historyPageSize
361
- const beforeTime = Number(koa.query.beforeTime ?? 0)
362
- let messages: SelfMessageRecord[] = []
363
- for (const candidate of historyChannelCandidates(channelId)) {
364
- messages = Number.isFinite(beforeTime) && beforeTime > 0
365
- ? await database.listSelfMessagesBefore(platform, selfId, candidate, beforeTime, limit)
366
- : await database.listSelfMessages(platform, selfId, candidate, limit)
367
- if (messages.length) break
368
- }
369
- messages.sort((a, b) => a.sentAt - b.sentAt)
370
- koa.body = { messages }
371
- })
372
-
373
- ctx.server.post(`${config.basePath}/api/self-messages`, async (koa) => {
374
- const requestBody = (koa.request as unknown as { body?: unknown }).body
375
- const body = (requestBody ?? {}) as Record<string, unknown>
376
- const payload: SelfMessagePayload = {
377
- id: typeof body.id === 'string' ? body.id : undefined,
378
- platform: String(body.platform ?? ''),
379
- selfId: String(body.selfId ?? ''),
380
- channelId: String(body.channelId ?? ''),
381
- guildId: typeof body.guildId === 'string' ? body.guildId : undefined,
382
- channelType: body.channelType === 'user' ? 'user' : 'group',
383
- messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
384
- content: typeof body.content === 'string' ? body.content : undefined,
385
- elements: Array.isArray(body.elements) ? body.elements as unknown[] : undefined,
386
- message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
387
- forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
388
- forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
389
- sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
390
- ? body.sentAt
391
- : Date.now(),
392
- sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
393
- ? body.sequence
394
- : 0,
395
- source: body.source === 'plugin' ? 'plugin' : 'webui',
396
- kind: typeof body.kind === 'string' ? body.kind : 'text',
397
- }
398
- if (!payload.platform || !payload.selfId || !payload.channelId) {
399
- koa.status = 400
400
- koa.body = { error: 'missing self-message params' }
401
- return
402
- }
403
- const record: SelfMessageRecord = {
404
- id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
405
- platform: payload.platform,
406
- selfId: payload.selfId,
407
- channelId: payload.channelId,
408
- guildId: payload.guildId,
409
- channelType: payload.channelType,
410
- messageId: payload.messageId,
411
- content: payload.content,
412
- elements: payload.elements,
413
- message: payload.message,
414
- forwardId: payload.forwardId,
415
- forwardContent: payload.forwardContent,
416
- sentAt: payload.sentAt ?? Date.now(),
417
- sequence: payload.sequence ?? 0,
418
- source: payload.source ?? 'webui',
419
- kind: payload.kind ?? 'text',
420
- }
421
- await database.upsertSelfMessage(record)
422
- koa.body = { ok: true, message: record }
423
- })
424
-
425
- ctx.server.get(`${config.basePath}/api/forward`, async (koa) => {
426
- const platform = String(koa.query.platform ?? '')
427
- const selfId = String(koa.query.selfId ?? '')
428
- const channelId = String(koa.query.channelId ?? '')
429
- const id = String(koa.query.id ?? '')
430
- if (!platform || !selfId || !id) {
431
- koa.status = 400
432
- koa.body = { error: 'missing forward params' }
433
- return
434
- }
435
- if (channelId) {
436
- const cached = await database.findSelfForwardContent(platform, selfId, channelId, id)
437
- if (cached) {
438
- koa.body = cached
439
- return
440
- }
441
- }
442
- if (platform !== 'onebot') {
443
- koa.status = 501
444
- koa.body = { error: 'forward api not supported' }
445
- return
446
- }
447
- const bot = ctx.bots.find((item) => {
448
- return item.platform === platform && item.selfId === selfId
449
- })
450
- if (!bot) {
451
- koa.status = 404
452
- koa.body = { error: 'bot not found' }
453
- return
454
- }
455
- const internal = (bot as unknown as {
456
- internal?: { getForwardMsg?: (messageId: string) => Promise<unknown> }
457
- }).internal
458
- if (!internal?.getForwardMsg) {
459
- koa.status = 501
460
- koa.body = { error: 'forward api not supported' }
461
- return
462
- }
463
- try {
464
- koa.body = await internal.getForwardMsg(id)
465
- } catch (error) {
466
- koa.status = 502
467
- koa.body = { error: 'forward api failed' }
468
- }
469
- })
470
-
471
- ctx.server.post(`${config.basePath}/api/send-forward`, async (koa) => {
472
- const requestBody = (koa.request as unknown as { body?: unknown }).body
473
- const body = (requestBody ?? {}) as Record<string, unknown>
474
- const platform = String(body.platform ?? '')
475
- const selfId = String(body.selfId ?? '')
476
- const type = String(body.type ?? '')
477
- const id = String(body.id ?? '')
478
- if (!platform || !selfId || !type || !id) {
479
- koa.status = 400
480
- koa.body = { error: 'missing send-forward params' }
481
- return
482
- }
483
- if (platform !== 'onebot') {
484
- koa.status = 501
485
- koa.body = { error: 'forward api not supported' }
486
- return
487
- }
488
- const bot = ctx.bots.find((item) => {
489
- return item.platform === platform && item.selfId === selfId
490
- })
491
- if (!bot) {
492
- koa.status = 404
493
- koa.body = { error: 'bot not found' }
494
- return
495
- }
496
- const internal = (bot as unknown as {
497
- internal?: {
498
- sendGroupForwardMsg?: (groupId: string, messages: unknown[]) => Promise<unknown>
499
- sendPrivateForwardMsg?: (userId: string, messages: unknown[]) => Promise<unknown>
500
- }
501
- }).internal
502
- if (type === 'group' && !internal?.sendGroupForwardMsg) {
503
- koa.status = 501
504
- koa.body = { error: 'forward api not supported' }
505
- return
506
- }
507
- if (type !== 'group' && !internal?.sendPrivateForwardMsg) {
508
- koa.status = 501
509
- koa.body = { error: 'forward api not supported' }
510
- return
511
- }
512
- const messages = Array.isArray(body.messages) ? body.messages as unknown[] : []
513
- const nodes = messages.map((raw) => {
514
- const item = typeof raw === 'object' && raw !== null
515
- ? raw as Record<string, unknown>
516
- : {}
517
- const nodeData = typeof item.data === 'object' && item.data !== null
518
- ? item.data as Record<string, unknown>
519
- : item
520
- const content = Array.isArray(item.content)
521
- ? item.content as unknown[]
522
- : Array.isArray(nodeData.content)
523
- ? nodeData.content as unknown[]
524
- : []
525
- const userId = String(item.user_id ?? nodeData.user_id ?? nodeData.uin ?? '')
526
- const nickname = String(item.nickname ?? nodeData.nickname ?? nodeData.name ?? '')
527
- return {
528
- type: 'node',
529
- data: {
530
- user_id: userId,
531
- nickname,
532
- // koishi 的 OneBot adapter 发送自定义转发节点时使用 uin/name
533
- uin: userId,
534
- name: nickname,
535
- time: String(item.time ?? nodeData.time ?? Math.floor(Date.now() / 1000)),
536
- content: content.map((segmentRaw) => {
537
- const segment = typeof segmentRaw === 'object' && segmentRaw !== null
538
- ? segmentRaw as Record<string, unknown>
539
- : {}
540
- const copy = { ...segment }
541
- const segmentType = String(copy.type ?? 'text')
542
- delete copy.type
543
- return { type: segmentType, data: copy }
544
- }),
545
- },
546
- }
547
- })
548
- try {
549
- // 必须从 internal 上直接调用,避免方法被取出后丢失 this
550
- koa.body = type === 'group'
551
- ? await internal?.sendGroupForwardMsg?.(id, nodes)
552
- : await internal?.sendPrivateForwardMsg?.(id, nodes)
553
- } catch (error) {
554
- koa.status = 502
555
- koa.body = { error: 'send forward failed' }
556
- }
557
- })
558
-
559
- ctx.server.get(`${config.basePath}/api/cache`, async (koa) => {
560
- const platform = String(koa.query.platform ?? '')
561
- let selfId = String(koa.query.selfId ?? '')
562
- if (!selfId) {
563
- selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
564
- }
565
- const type = String(koa.query.type ?? '')
566
- if (!platform || !selfId || !type) {
567
- koa.status = 400
568
- koa.body = { error: 'missing cache params' }
569
- return
570
- }
571
-
572
- // 单条身份缓存:前端收消息时优先走这里,命中后不再请求 Satori API
573
- const id = String(koa.query.userId ?? koa.query.groupId ?? koa.query.id ?? '')
574
- if (id) {
575
- if (type === 'user' || type === 'friend') {
576
- const channelId = String(koa.query.channelId ?? '')
577
- const contact = await contactCache.getUser(
578
- platform,
579
- selfId,
580
- id,
581
- String(koa.query.guildId ?? ''),
582
- channelId,
583
- String(koa.query.name ?? ''),
584
- String(koa.query.avatar ?? ''),
585
- )
586
- koa.body = contact ?? null
587
- return
588
- }
589
- if (type === 'group') {
590
- const guildId = String(koa.query.guildId ?? '')
591
- const channelId = String(koa.query.channelId ?? '')
592
- const channelType = koa.query.channelType
593
- const contact = await contactCache.getGroup(
594
- platform,
595
- selfId,
596
- id,
597
- guildId,
598
- channelId,
599
- String(koa.query.name ?? ''),
600
- String(koa.query.avatar ?? ''),
601
- channelType,
602
- )
603
- koa.body = contact ?? null
604
- return
605
- }
606
- if (type === 'member') {
607
- const groupId = String(koa.query.groupId ?? koa.query.id ?? '')
608
- if (!groupId) {
609
- koa.status = 400
610
- koa.body = { error: 'missing groupId' }
611
- return
612
- }
613
- const memberId = String(koa.query.userId ?? '')
614
- if (memberId) {
615
- koa.body = await database.getGroupMember(platform, selfId, groupId, memberId) ?? null
616
- return
617
- }
618
- koa.body = {
619
- members: await database.getGroupMembers(platform, selfId, groupId),
620
- }
621
- return
622
- }
623
- koa.status = 400
624
- koa.body = { error: 'unsupported cache type' }
625
- return
626
- }
627
-
628
- koa.body = {
629
- contacts: await database.getContacts(platform, selfId, cacheType(type)),
630
- }
631
- })
632
-
633
- ctx.server.post(`${config.basePath}/api/cache`, async (koa) => {
634
- const requestBody = (koa.request as unknown as { body?: unknown }).body
635
- const body = (requestBody ?? {}) as Record<string, unknown>
636
- const platform = String(body.platform ?? '')
637
- let selfId = String(body.selfId ?? '')
638
- if (!selfId) {
639
- selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
640
- }
641
- const type = String(body.type ?? '')
642
- if (!platform || !selfId || !type) {
643
- koa.status = 400
644
- koa.body = { error: 'missing cache params' }
645
- return
646
- }
647
- const normalizedType = cacheType(type)
648
- if (type === 'member' && body.groupId) {
649
- const groupId = String(body.groupId)
650
- const contacts = Array.isArray(body.contacts) ? body.contacts as ContactCacheItem[] : []
651
- if (body.append === true) {
652
- for (const contact of contacts) {
653
- await database.appendGroupMember(platform, selfId, groupId, contact)
654
- }
655
- } else {
656
- await database.setGroupMembers(platform, selfId, groupId, contacts)
657
- }
658
- koa.body = {
659
- members: await database.getGroupMembers(platform, selfId, groupId),
660
- }
661
- return
662
- }
663
- if (Array.isArray(body.contacts)) {
664
- const contacts = body.contacts as ContactCacheItem[]
665
- if (body.append === true) {
666
- for (const contact of contacts) {
667
- await database.appendContact(platform, selfId, normalizedType, contact)
668
- }
669
- } else {
670
- await database.setContacts(platform, selfId, normalizedType, contacts)
671
- }
672
- }
673
- koa.body = {
674
- contacts: await database.getContacts(platform, selfId, normalizedType),
675
- }
676
- })
677
-
678
- const serveFile = async (koa: WebContext, fileName: string) => {
679
- const fullPath = path.join(webRoot, fileName)
680
- if (existsSync(fullPath) && statSync(fullPath).isFile()) {
681
- await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
682
- return
683
- }
684
- koa.status = 200
685
- koa.body = ''
686
- await send(koa, 'index.html', { root: webRoot })
687
- }
688
-
689
- const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
690
- const vite = getVite()
691
- if (!vite) return false
692
- koa.status = 200
693
- koa.type = 'html'
694
- koa.body = await transformWebIndex(vite, koa.path)
695
- return true
696
- }
697
-
698
- ctx.server.get(`${config.basePath}/web(/.*)?`, async (koa) => {
699
- if (await serveDevWeb(koa)) return
700
- if (!existsSync(webRoot)) {
701
- koa.status = 404
702
- koa.body = ''
703
- return
704
- }
705
- const fileName = koa.params?.[0]?.replace(/^\/+/, '') || 'index.html'
706
- await serveFile(koa, fileName)
707
- })
708
-
709
- if (!existsSync(webRoot) && process.env.NODE_ENV !== 'development') {
710
- logger.warn('未找到 client/web/dist,发布前请先执行 client/web 目录下的 npm run build')
711
- }
712
- }
1
+ import { Context } from 'koishi'
2
+ import {} from '@koishijs/plugin-server'
3
+ import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
4
+ import { createHash } from 'node:crypto'
5
+ import path from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import send from 'koa-send'
8
+ import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
9
+ import FileType from 'file-type'
10
+
11
+ import { Config } from './config'
12
+ import { ContactCacheService } from './cache'
13
+ import { ChatDatabase } from './database'
14
+ import { PluginLogger } from './logger'
15
+ import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
16
+
17
+ interface ViteConsoleServer {
18
+ config?: {
19
+ server?: {
20
+ preTransformRequests?: boolean
21
+ }
22
+ }
23
+ transformIndexHtml(url: string, html: string, originalUrl?: string): Promise<string>
24
+ }
25
+
26
+ interface ViteConsoleLike {
27
+ vite?: ViteConsoleServer
28
+ }
29
+
30
+ export function registerWeb(
31
+ ctx: Context,
32
+ config: Config,
33
+ database: ChatDatabase,
34
+ contactCache: ContactCacheService,
35
+ logger: PluginLogger,
36
+ ) {
37
+ const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
38
+ const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
39
+ const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
40
+ const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
41
+ const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
42
+
43
+ type WebContext = ParameterizedContext<DefaultState, DefaultContext>
44
+ const cacheType = (type: string) => type === 'user' ? 'friend' : type
45
+
46
+ const getVite = (): ViteConsoleServer | undefined => {
47
+ return (ctx.console as unknown as ViteConsoleLike).vite
48
+ }
49
+
50
+ const viteFsUrl = (target: string): string => {
51
+ return `/vite/@fs/${path.resolve(target).split(path.sep).join('/')}`
52
+ }
53
+
54
+ const transformWebIndex = async (vite: ViteConsoleServer, requestPath: string) => {
55
+ // 源码由 iframe 按需加载,避免 Koishi Vite 启动时提前转换整个 web 应用
56
+ if (vite.config?.server) vite.config.server.preTransformRequests = false
57
+ const mainFsUrl = `/@fs/${path.resolve(webSource, 'main.ts').split(path.sep).join('/')}`
58
+ const html = (await fs.readFile(webIndex, 'utf8')).replace(
59
+ 'src="/src/main.ts"',
60
+ `src="${mainFsUrl}"`,
61
+ )
62
+ let transformed = await vite.transformIndexHtml('/chat-patch/web/index.html', html, requestPath)
63
+ transformed = transformed
64
+ .replace(/\/vite\/bcui/g, viteFsUrl(path.join(webPublic, 'bcui')))
65
+ .replace(/\/vite\/css/g, viteFsUrl(path.join(webPublic, 'css')))
66
+ return transformed
67
+ }
68
+
69
+ const servePublicFile = async (koa: WebContext, prefix: string) => {
70
+ const relative = (koa.params?.[0] ?? '').replace(/^\/+/, '')
71
+ const fullPath = path.resolve(webPublic, prefix, relative)
72
+ if (!fullPath.startsWith(webPublic + path.sep)) {
73
+ koa.status = 403
74
+ koa.body = ''
75
+ return
76
+ }
77
+ if (existsSync(fullPath) && statSync(fullPath).isFile()) {
78
+ await send(koa, path.relative(webPublic, fullPath), { root: webPublic })
79
+ return
80
+ }
81
+ koa.status = 404
82
+ koa.body = ''
83
+ }
84
+
85
+ // web 应用里仍有 /img 这类运行时绝对路径,开发模式直接由 Koishi 服务托管
86
+ const registerDevPublic = (pattern: string, prefix: string) => {
87
+ ctx.server.get(pattern, async (koa: WebContext, next: () => Promise<void>) => {
88
+ if (!getVite()) return next()
89
+ await servePublicFile(koa, prefix)
90
+ })
91
+ }
92
+
93
+ registerDevPublic('/img(.*)', 'img')
94
+
95
+ const toExtension = (value: string): string => {
96
+ const ext = value.startsWith('.') ? value : `.${value}`
97
+ return ext.toLowerCase()
98
+ }
99
+
100
+ const normalizeGroupId = (value: string): string => {
101
+ const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
102
+ const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
103
+ return wrapped ? wrapped[1] : raw || value
104
+ }
105
+
106
+ const historyChannelCandidates = (channelId: string): string[] => {
107
+ const raw = normalizeGroupId(channelId)
108
+ const candidates = [channelId]
109
+ if (raw && raw !== channelId) candidates.push(raw)
110
+ const id = raw || channelId
111
+ if (!/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) {
112
+ candidates.push(`group:${id}`, `private:${id}`)
113
+ }
114
+ if (id) {
115
+ candidates.push(` [_${id}_] `, `_${id}_`, `[${id}]`, `group:${id}`)
116
+ }
117
+ return [...new Set(candidates)]
118
+ }
119
+
120
+ const mimeToExt: Record<string, string> = {
121
+ 'audio/mpeg': '.mp3',
122
+ 'audio/mp3': '.mp3',
123
+ 'audio/mp4': '.m4a',
124
+ 'audio/x-m4a': '.m4a',
125
+ 'audio/wav': '.wav',
126
+ 'audio/x-wav': '.wav',
127
+ 'audio/webm': '.webm',
128
+ 'audio/ogg': '.ogg',
129
+ 'audio/aac': '.aac',
130
+ 'audio/flac': '.flac',
131
+ 'image/jpeg': '.jpg',
132
+ 'image/png': '.png',
133
+ 'image/gif': '.gif',
134
+ 'image/webp': '.webp',
135
+ 'video/mp4': '.mp4',
136
+ 'video/webm': '.webm',
137
+ 'video/quicktime': '.mov',
138
+ 'video/x-msvideo': '.avi',
139
+ 'video/x-matroska': '.mkv',
140
+ 'video/x-flv': '.flv',
141
+ 'video/3gpp': '.3gp',
142
+ 'video/ogg': '.ogv',
143
+ 'video/mp2t': '.ts',
144
+ }
145
+ const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
146
+
147
+ ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
148
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
149
+ const body = (requestBody ?? {}) as Record<string, unknown>
150
+ let source = String(body.dataUrl ?? body.data ?? '')
151
+ let name = String(body.name ?? '')
152
+ let mime = ''
153
+ let buffer: Buffer | null = null
154
+ if (source) {
155
+ if (source.startsWith('base64://')) source = source.slice(9)
156
+ const comma = source.indexOf('base64,')
157
+ const base64 = comma >= 0 ? source.slice(comma + 7) : source
158
+ const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
159
+ try {
160
+ buffer = Buffer.from(normalizedBase64, 'base64')
161
+ } catch {
162
+ koa.status = 400
163
+ koa.body = { error: 'invalid media data' }
164
+ return
165
+ }
166
+ mime = source.startsWith('data:')
167
+ ? source.slice(5, source.indexOf(';')).toLowerCase()
168
+ : ''
169
+ } else {
170
+ const chunks: Buffer[] = []
171
+ for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
172
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
173
+ }
174
+ buffer = Buffer.concat(chunks)
175
+ name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
176
+ try {
177
+ name = decodeURIComponent(name)
178
+ } catch {
179
+ // 保持原始名称即可
180
+ }
181
+ mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
182
+ }
183
+ if (!buffer || !buffer.length) {
184
+ koa.status = 400
185
+ koa.body = { error: 'missing media data' }
186
+ return
187
+ }
188
+ if (buffer.length > MAX_UPLOAD_BYTES) {
189
+ koa.status = 413
190
+ koa.body = { error: 'media file too large' }
191
+ return
192
+ }
193
+ if (!buffer.length) {
194
+ koa.status = 400
195
+ koa.body = { error: 'invalid media data' }
196
+ return
197
+ }
198
+
199
+ await fs.mkdir(uploadDir, { recursive: true })
200
+ let detected: { ext?: string } | undefined
201
+ try {
202
+ const result = await FileType.fromBuffer(buffer)
203
+ detected = result ?? undefined
204
+ } catch {
205
+ // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
206
+ detected = undefined
207
+ }
208
+ const mimeExt = mimeToExt[mime] || ''
209
+ const nameExt = path.extname(name).toLowerCase()
210
+ const ext = detected?.ext
211
+ ? toExtension(detected.ext)
212
+ : mimeExt || nameExt || '.bin'
213
+ const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
214
+ const filePath = path.join(uploadDir, filename)
215
+ if (!existsSync(filePath)) {
216
+ await fs.writeFile(filePath, buffer)
217
+ }
218
+ koa.body = {
219
+ path: pathToFileURL(filePath).href,
220
+ localPath: filePath,
221
+ }
222
+ })
223
+
224
+ ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
225
+ const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
226
+ if (!fileName || fileName === '.' || fileName === '..') {
227
+ koa.status = 400
228
+ koa.body = { error: 'missing media file' }
229
+ return
230
+ }
231
+ const filePath = path.resolve(uploadDir, fileName)
232
+ const root = path.resolve(uploadDir)
233
+ if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
234
+ koa.status = 400
235
+ koa.body = { error: 'invalid media file' }
236
+ return
237
+ }
238
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
239
+ koa.status = 404
240
+ koa.body = { error: 'media file not found' }
241
+ return
242
+ }
243
+ const size = statSync(filePath).size
244
+ koa.set('Accept-Ranges', 'bytes')
245
+ if (fileName.toLowerCase().endsWith('.webm')) {
246
+ koa.type = 'audio/webm'
247
+ } else {
248
+ koa.type = fileName
249
+ }
250
+ const range = koa.headers.range
251
+ if (range) {
252
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
253
+ if (!match) {
254
+ koa.status = 416
255
+ koa.set('Content-Range', `bytes */${size}`)
256
+ koa.body = ''
257
+ return
258
+ }
259
+ let start = 0
260
+ let end = size - 1
261
+ if (match[1] === '' && match[2]) {
262
+ start = Math.max(0, size - Number(match[2]))
263
+ } else if (match[1]) {
264
+ start = Number(match[1])
265
+ end = match[2] ? Number(match[2]) : end
266
+ }
267
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
268
+ koa.status = 416
269
+ koa.set('Content-Range', `bytes */${size}`)
270
+ koa.body = ''
271
+ return
272
+ }
273
+ if (end >= size) end = size - 1
274
+ if (end < start) end = start
275
+ koa.status = 206
276
+ koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
277
+ koa.set('Content-Length', String(end - start + 1))
278
+ koa.body = createReadStream(filePath, { start, end })
279
+ return
280
+ }
281
+ koa.set('Content-Length', String(size))
282
+ koa.body = createReadStream(filePath)
283
+ })
284
+
285
+ ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
286
+ const entries = await database.getAllContacts()
287
+ const botMap = new Map<string, {
288
+ platform: string
289
+ selfId: string
290
+ groups: ContactCacheItem[]
291
+ friends: ContactCacheItem[]
292
+ }>()
293
+ for (const entry of entries) {
294
+ const key = JSON.stringify([entry.platform, entry.selfId])
295
+ const bot = botMap.get(key) ?? {
296
+ platform: entry.platform,
297
+ selfId: entry.selfId,
298
+ groups: [],
299
+ friends: [],
300
+ }
301
+ if (entry.type === 'group') bot.groups = entry.contacts
302
+ if (entry.type === 'friend') bot.friends = entry.contacts
303
+ botMap.set(key, bot)
304
+ }
305
+ koa.body = { bots: [...botMap.values()] }
306
+ })
307
+
308
+ ctx.server.get(`${config.basePath}/api/history`, async (koa) => {
309
+ const platform = String(koa.query.platform ?? '')
310
+ const selfId = String(koa.query.selfId ?? '')
311
+ const channelId = String(koa.query.channelId ?? '')
312
+ if (!platform || !selfId || !channelId) {
313
+ koa.status = 400
314
+ koa.body = { error: 'missing history params' }
315
+ return
316
+ }
317
+ const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
318
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
319
+ const beforeTime = Number(koa.query.beforeTime ?? 0)
320
+ const queryMessages = async (cid: string) => {
321
+ return Number.isFinite(beforeTime) && beforeTime > 0
322
+ ? database.listMessagesBefore(platform, selfId, cid, beforeTime, limit)
323
+ : database.listMessages(platform, selfId, cid, limit)
324
+ }
325
+ let messages: Awaited<ReturnType<typeof queryMessages>> = []
326
+ for (const candidate of historyChannelCandidates(channelId)) {
327
+ messages = await queryMessages(candidate)
328
+ if (messages.length) break
329
+ }
330
+ let selfMessages: SelfMessageRecord[] = []
331
+ const querySelfMessages = async (cid: string): Promise<SelfMessageRecord[]> => {
332
+ return Number.isFinite(beforeTime) && beforeTime > 0
333
+ ? database.listSelfMessagesBefore(platform, selfId, cid, beforeTime, limit)
334
+ : database.listSelfMessages(platform, selfId, cid, limit)
335
+ }
336
+ for (const candidate of historyChannelCandidates(channelId)) {
337
+ selfMessages = await querySelfMessages(candidate)
338
+ if (selfMessages.length) break
339
+ }
340
+ // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
341
+ messages.sort((a, b) => {
342
+ const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
343
+ const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
344
+ return timeA - timeB
345
+ })
346
+ selfMessages.sort((a, b) => a.sentAt - b.sentAt)
347
+ koa.body = { messages, selfMessages }
348
+ })
349
+
350
+ ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
351
+ const platform = String(koa.query.platform ?? '')
352
+ const selfId = String(koa.query.selfId ?? '')
353
+ const channelId = String(koa.query.channelId ?? '')
354
+ if (!platform || !selfId || !channelId) {
355
+ koa.status = 400
356
+ koa.body = { error: 'missing self-message params' }
357
+ return
358
+ }
359
+ const parsedLimit = Number(koa.query.limit ?? config.historyPageSize)
360
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.historyPageSize
361
+ const beforeTime = Number(koa.query.beforeTime ?? 0)
362
+ let messages: SelfMessageRecord[] = []
363
+ for (const candidate of historyChannelCandidates(channelId)) {
364
+ messages = Number.isFinite(beforeTime) && beforeTime > 0
365
+ ? await database.listSelfMessagesBefore(platform, selfId, candidate, beforeTime, limit)
366
+ : await database.listSelfMessages(platform, selfId, candidate, limit)
367
+ if (messages.length) break
368
+ }
369
+ messages.sort((a, b) => a.sentAt - b.sentAt)
370
+ koa.body = { messages }
371
+ })
372
+
373
+ ctx.server.post(`${config.basePath}/api/self-messages`, async (koa) => {
374
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
375
+ const body = (requestBody ?? {}) as Record<string, unknown>
376
+ const payload: SelfMessagePayload = {
377
+ id: typeof body.id === 'string' ? body.id : undefined,
378
+ platform: String(body.platform ?? ''),
379
+ selfId: String(body.selfId ?? ''),
380
+ channelId: String(body.channelId ?? ''),
381
+ guildId: typeof body.guildId === 'string' ? body.guildId : undefined,
382
+ channelType: body.channelType === 'user' ? 'user' : 'group',
383
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
384
+ content: typeof body.content === 'string' ? body.content : undefined,
385
+ elements: Array.isArray(body.elements) ? body.elements as unknown[] : undefined,
386
+ message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
387
+ forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
388
+ forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
389
+ sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
390
+ ? body.sentAt
391
+ : Date.now(),
392
+ sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
393
+ ? body.sequence
394
+ : 0,
395
+ source: body.source === 'plugin' ? 'plugin' : 'webui',
396
+ kind: typeof body.kind === 'string' ? body.kind : 'text',
397
+ }
398
+ if (!payload.platform || !payload.selfId || !payload.channelId) {
399
+ koa.status = 400
400
+ koa.body = { error: 'missing self-message params' }
401
+ return
402
+ }
403
+ const record: SelfMessageRecord = {
404
+ id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
405
+ platform: payload.platform,
406
+ selfId: payload.selfId,
407
+ channelId: payload.channelId,
408
+ guildId: payload.guildId,
409
+ channelType: payload.channelType,
410
+ messageId: payload.messageId,
411
+ content: payload.content,
412
+ elements: payload.elements,
413
+ message: payload.message,
414
+ forwardId: payload.forwardId,
415
+ forwardContent: payload.forwardContent,
416
+ sentAt: payload.sentAt ?? Date.now(),
417
+ sequence: payload.sequence ?? 0,
418
+ source: payload.source ?? 'webui',
419
+ kind: payload.kind ?? 'text',
420
+ }
421
+ await database.upsertSelfMessage(record)
422
+ koa.body = { ok: true, message: record }
423
+ })
424
+
425
+ ctx.server.get(`${config.basePath}/api/forward`, async (koa) => {
426
+ const platform = String(koa.query.platform ?? '')
427
+ const selfId = String(koa.query.selfId ?? '')
428
+ const channelId = String(koa.query.channelId ?? '')
429
+ const id = String(koa.query.id ?? '')
430
+ if (!platform || !selfId || !id) {
431
+ koa.status = 400
432
+ koa.body = { error: 'missing forward params' }
433
+ return
434
+ }
435
+ if (channelId) {
436
+ const cached = await database.findSelfForwardContent(platform, selfId, channelId, id)
437
+ if (cached) {
438
+ koa.body = cached
439
+ return
440
+ }
441
+ }
442
+ if (platform !== 'onebot') {
443
+ koa.status = 501
444
+ koa.body = { error: 'forward api not supported' }
445
+ return
446
+ }
447
+ const bot = ctx.bots.find((item) => {
448
+ return item.platform === platform && item.selfId === selfId
449
+ })
450
+ if (!bot) {
451
+ koa.status = 404
452
+ koa.body = { error: 'bot not found' }
453
+ return
454
+ }
455
+ const internal = (bot as unknown as {
456
+ internal?: { getForwardMsg?: (messageId: string) => Promise<unknown> }
457
+ }).internal
458
+ if (!internal?.getForwardMsg) {
459
+ koa.status = 501
460
+ koa.body = { error: 'forward api not supported' }
461
+ return
462
+ }
463
+ try {
464
+ koa.body = await internal.getForwardMsg(id)
465
+ } catch (error) {
466
+ koa.status = 502
467
+ koa.body = { error: 'forward api failed' }
468
+ }
469
+ })
470
+
471
+ ctx.server.post(`${config.basePath}/api/send-forward`, async (koa) => {
472
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
473
+ const body = (requestBody ?? {}) as Record<string, unknown>
474
+ const platform = String(body.platform ?? '')
475
+ const selfId = String(body.selfId ?? '')
476
+ const type = String(body.type ?? '')
477
+ const id = String(body.id ?? '')
478
+ if (!platform || !selfId || !type || !id) {
479
+ koa.status = 400
480
+ koa.body = { error: 'missing send-forward params' }
481
+ return
482
+ }
483
+ if (platform !== 'onebot') {
484
+ koa.status = 501
485
+ koa.body = { error: 'forward api not supported' }
486
+ return
487
+ }
488
+ const bot = ctx.bots.find((item) => {
489
+ return item.platform === platform && item.selfId === selfId
490
+ })
491
+ if (!bot) {
492
+ koa.status = 404
493
+ koa.body = { error: 'bot not found' }
494
+ return
495
+ }
496
+ const internal = (bot as unknown as {
497
+ internal?: {
498
+ sendGroupForwardMsg?: (groupId: string, messages: unknown[]) => Promise<unknown>
499
+ sendPrivateForwardMsg?: (userId: string, messages: unknown[]) => Promise<unknown>
500
+ }
501
+ }).internal
502
+ if (type === 'group' && !internal?.sendGroupForwardMsg) {
503
+ koa.status = 501
504
+ koa.body = { error: 'forward api not supported' }
505
+ return
506
+ }
507
+ if (type !== 'group' && !internal?.sendPrivateForwardMsg) {
508
+ koa.status = 501
509
+ koa.body = { error: 'forward api not supported' }
510
+ return
511
+ }
512
+ const messages = Array.isArray(body.messages) ? body.messages as unknown[] : []
513
+ const nodes = messages.map((raw) => {
514
+ const item = typeof raw === 'object' && raw !== null
515
+ ? raw as Record<string, unknown>
516
+ : {}
517
+ const nodeData = typeof item.data === 'object' && item.data !== null
518
+ ? item.data as Record<string, unknown>
519
+ : item
520
+ const content = Array.isArray(item.content)
521
+ ? item.content as unknown[]
522
+ : Array.isArray(nodeData.content)
523
+ ? nodeData.content as unknown[]
524
+ : []
525
+ const userId = String(item.user_id ?? nodeData.user_id ?? nodeData.uin ?? '')
526
+ const nickname = String(item.nickname ?? nodeData.nickname ?? nodeData.name ?? '')
527
+ return {
528
+ type: 'node',
529
+ data: {
530
+ user_id: userId,
531
+ nickname,
532
+ // koishi 的 OneBot adapter 发送自定义转发节点时使用 uin/name
533
+ uin: userId,
534
+ name: nickname,
535
+ time: String(item.time ?? nodeData.time ?? Math.floor(Date.now() / 1000)),
536
+ content: content.map((segmentRaw) => {
537
+ const segment = typeof segmentRaw === 'object' && segmentRaw !== null
538
+ ? segmentRaw as Record<string, unknown>
539
+ : {}
540
+ const copy = { ...segment }
541
+ const segmentType = String(copy.type ?? 'text')
542
+ delete copy.type
543
+ return { type: segmentType, data: copy }
544
+ }),
545
+ },
546
+ }
547
+ })
548
+ try {
549
+ // 必须从 internal 上直接调用,避免方法被取出后丢失 this
550
+ koa.body = type === 'group'
551
+ ? await internal?.sendGroupForwardMsg?.(id, nodes)
552
+ : await internal?.sendPrivateForwardMsg?.(id, nodes)
553
+ } catch (error) {
554
+ koa.status = 502
555
+ koa.body = { error: 'send forward failed' }
556
+ }
557
+ })
558
+
559
+ ctx.server.get(`${config.basePath}/api/cache`, async (koa) => {
560
+ const platform = String(koa.query.platform ?? '')
561
+ let selfId = String(koa.query.selfId ?? '')
562
+ if (!selfId) {
563
+ selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
564
+ }
565
+ const type = String(koa.query.type ?? '')
566
+ if (!platform || !selfId || !type) {
567
+ koa.status = 400
568
+ koa.body = { error: 'missing cache params' }
569
+ return
570
+ }
571
+
572
+ // 单条身份缓存:前端收消息时优先走这里,命中后不再请求 Satori API
573
+ const id = String(koa.query.userId ?? koa.query.groupId ?? koa.query.id ?? '')
574
+ if (id) {
575
+ if (type === 'user' || type === 'friend') {
576
+ const channelId = String(koa.query.channelId ?? '')
577
+ const contact = await contactCache.getUser(
578
+ platform,
579
+ selfId,
580
+ id,
581
+ String(koa.query.guildId ?? ''),
582
+ channelId,
583
+ String(koa.query.name ?? ''),
584
+ String(koa.query.avatar ?? ''),
585
+ )
586
+ koa.body = contact ?? null
587
+ return
588
+ }
589
+ if (type === 'group') {
590
+ const guildId = String(koa.query.guildId ?? '')
591
+ const channelId = String(koa.query.channelId ?? '')
592
+ const channelType = koa.query.channelType
593
+ const contact = await contactCache.getGroup(
594
+ platform,
595
+ selfId,
596
+ id,
597
+ guildId,
598
+ channelId,
599
+ String(koa.query.name ?? ''),
600
+ String(koa.query.avatar ?? ''),
601
+ channelType,
602
+ )
603
+ koa.body = contact ?? null
604
+ return
605
+ }
606
+ if (type === 'member') {
607
+ const groupId = String(koa.query.groupId ?? koa.query.id ?? '')
608
+ if (!groupId) {
609
+ koa.status = 400
610
+ koa.body = { error: 'missing groupId' }
611
+ return
612
+ }
613
+ const memberId = String(koa.query.userId ?? '')
614
+ if (memberId) {
615
+ koa.body = await database.getGroupMember(platform, selfId, groupId, memberId) ?? null
616
+ return
617
+ }
618
+ koa.body = {
619
+ members: await database.getGroupMembers(platform, selfId, groupId),
620
+ }
621
+ return
622
+ }
623
+ koa.status = 400
624
+ koa.body = { error: 'unsupported cache type' }
625
+ return
626
+ }
627
+
628
+ koa.body = {
629
+ contacts: await database.getContacts(platform, selfId, cacheType(type)),
630
+ }
631
+ })
632
+
633
+ ctx.server.post(`${config.basePath}/api/cache`, async (koa) => {
634
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
635
+ const body = (requestBody ?? {}) as Record<string, unknown>
636
+ const platform = String(body.platform ?? '')
637
+ let selfId = String(body.selfId ?? '')
638
+ if (!selfId) {
639
+ selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
640
+ }
641
+ const type = String(body.type ?? '')
642
+ if (!platform || !selfId || !type) {
643
+ koa.status = 400
644
+ koa.body = { error: 'missing cache params' }
645
+ return
646
+ }
647
+ const normalizedType = cacheType(type)
648
+ if (type === 'member' && body.groupId) {
649
+ const groupId = String(body.groupId)
650
+ const contacts = Array.isArray(body.contacts) ? body.contacts as ContactCacheItem[] : []
651
+ if (body.append === true) {
652
+ for (const contact of contacts) {
653
+ await database.appendGroupMember(platform, selfId, groupId, contact)
654
+ }
655
+ } else {
656
+ await database.setGroupMembers(platform, selfId, groupId, contacts)
657
+ }
658
+ koa.body = {
659
+ members: await database.getGroupMembers(platform, selfId, groupId),
660
+ }
661
+ return
662
+ }
663
+ if (Array.isArray(body.contacts)) {
664
+ const contacts = body.contacts as ContactCacheItem[]
665
+ if (body.append === true) {
666
+ for (const contact of contacts) {
667
+ await database.appendContact(platform, selfId, normalizedType, contact)
668
+ }
669
+ } else {
670
+ await database.setContacts(platform, selfId, normalizedType, contacts)
671
+ }
672
+ }
673
+ koa.body = {
674
+ contacts: await database.getContacts(platform, selfId, normalizedType),
675
+ }
676
+ })
677
+
678
+ const serveFile = async (koa: WebContext, fileName: string) => {
679
+ const fullPath = path.join(webRoot, fileName)
680
+ if (existsSync(fullPath) && statSync(fullPath).isFile()) {
681
+ await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
682
+ return
683
+ }
684
+ koa.status = 200
685
+ koa.body = ''
686
+ await send(koa, 'index.html', { root: webRoot })
687
+ }
688
+
689
+ const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
690
+ const vite = getVite()
691
+ if (!vite) return false
692
+ koa.status = 200
693
+ koa.type = 'html'
694
+ koa.body = await transformWebIndex(vite, koa.path)
695
+ return true
696
+ }
697
+
698
+ ctx.server.get(`${config.basePath}/web(/.*)?`, async (koa) => {
699
+ if (await serveDevWeb(koa)) return
700
+ if (!existsSync(webRoot)) {
701
+ koa.status = 404
702
+ koa.body = ''
703
+ return
704
+ }
705
+ const fileName = koa.params?.[0]?.replace(/^\/+/, '') || 'index.html'
706
+ await serveFile(koa, fileName)
707
+ })
708
+
709
+ if (!existsSync(webRoot) && process.env.NODE_ENV !== 'development') {
710
+ logger.warn('未找到 client/web/dist,发布前请先执行 client/web 目录下的 npm run build')
711
+ }
712
+ }