koishi-plugin-chat-patch 6.0.0 → 6.1.1

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.
package/src/database.ts CHANGED
@@ -7,11 +7,17 @@ import { Config } from './config'
7
7
  import { ContactCacheItem, MessageRecord, PinnedState, SelfMessageRecord } from './types'
8
8
  import { PluginLogger } from './logger'
9
9
 
10
- function encodeKeyPart(value: string): string {
11
- return encodeURIComponent(value)
12
- }
13
-
14
- function decodeKeyPart(value: string): string {
10
+ function encodeKeyPart(value: string): string {
11
+ return encodeURIComponent(value)
12
+ }
13
+
14
+ function normalizeTimestampMs(value: unknown): number {
15
+ const num = Number(value ?? 0)
16
+ if (!Number.isFinite(num) || num <= 0) return 0
17
+ return num > 1e12 ? num : num * 1000
18
+ }
19
+
20
+ function decodeKeyPart(value: string): string {
15
21
  try {
16
22
  return decodeURIComponent(value)
17
23
  } catch {
@@ -27,11 +33,11 @@ function legacyMessagePrefix(platform: string, selfId: string, channelId: string
27
33
  return `m:${platform}:${selfId}:${channelId}:`
28
34
  }
29
35
 
30
- function messageKey(record: MessageRecord): string {
31
- // 统一用毫秒排序,前端 beforeTime 也传毫秒,避免按秒存储时分页取到同一批消息
32
- const time = String(
33
- record.receivedAt ?? record.timestampMs ?? Number(record.timestamp) * 1000,
34
- ).padStart(16, '0')
36
+ function messageKey(record: MessageRecord): string {
37
+ // 统一用毫秒排序,前端 beforeTime 也传毫秒,避免按秒存储时分页取到同一批消息
38
+ const time = String(
39
+ normalizeTimestampMs(record.timestampMs ?? record.timestamp ?? record.receivedAt),
40
+ ).padStart(16, '0')
35
41
  return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
36
42
  }
37
43
 
@@ -40,8 +46,10 @@ function selfMessagePrefix(platform: string, selfId: string, channelId: string):
40
46
  return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
41
47
  }
42
48
 
43
- function selfMessageKey(record: SelfMessageRecord): string {
44
- const time = String(record.sentAt).padStart(16, '0')
49
+ function selfMessageKey(record: SelfMessageRecord): string {
50
+ const time = String(
51
+ normalizeTimestampMs(record.timestampMs ?? record.timestamp ?? record.sentAt),
52
+ ).padStart(16, '0')
45
53
  return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
46
54
  }
47
55
 
@@ -932,12 +940,12 @@ export class ChatDatabase {
932
940
  this.logger.logInfo(`频道历史已裁剪 ${keys.length} 条`)
933
941
  }
934
942
 
935
- private extractRecordTime(value: string): number {
936
- try {
937
- const parsed = JSON.parse(value) as Record<string, unknown>
938
- return Number(parsed.receivedAt ?? parsed.timestampMs ?? parsed.timestamp ?? parsed.sentAt ?? 0) || 0
939
- } catch {
940
- return 0
941
- }
943
+ private extractRecordTime(value: string): number {
944
+ try {
945
+ const parsed = JSON.parse(value) as Record<string, unknown>
946
+ return normalizeTimestampMs(parsed.timestampMs ?? parsed.timestamp ?? parsed.sentAt ?? parsed.receivedAt)
947
+ } catch {
948
+ return 0
949
+ }
942
950
  }
943
951
  }
package/src/recorder.ts CHANGED
@@ -65,6 +65,7 @@ export class Recorder {
65
65
  const user = getObject(body.user)
66
66
  const sn = getNumber(body.sn)
67
67
  const timestamp = getNumber(body.timestamp) || Date.now()
68
+ const timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000
68
69
  const record: MessageRecord = {
69
70
  id: getString(message.id) || `satori-${sn}`,
70
71
  sequence: sn,
@@ -75,7 +76,7 @@ export class Recorder {
75
76
  guildId: getString(guild.id) || undefined,
76
77
  userId: getString(user.id) || undefined,
77
78
  timestamp,
78
- timestampMs: timestamp,
79
+ timestampMs,
79
80
  receivedAt: Date.now(),
80
81
  content: getString(message.content) || getString(message.raw_message) || undefined,
81
82
  elements: Array.isArray(message.elements) ? message.elements as unknown[] : undefined,
@@ -442,6 +442,7 @@ export class SelfMessageRecorder {
442
442
  const contentText = typeof content === 'string'
443
443
  ? content
444
444
  : h.toElementArray(content).join('')
445
+ const sentAt = Date.now()
445
446
  const fingerprint = createHash('sha256')
446
447
  .update([
447
448
  platform,
@@ -463,7 +464,9 @@ export class SelfMessageRecorder {
463
464
  message: toSegments(elements, (attrs) => this.resolveI18nElement(attrs)),
464
465
  forwardId: forwardId(elements),
465
466
  forwardContent: toForwardNodes(elements, (attrs) => this.resolveI18nElement(attrs)),
466
- sentAt: Date.now(),
467
+ sentAt,
468
+ timestamp: Math.floor(sentAt / 1000),
469
+ timestampMs: sentAt,
467
470
  sequence: ++this.sequence,
468
471
  source,
469
472
  kind: detectKind(elements),
package/src/types.ts CHANGED
@@ -66,6 +66,8 @@ export interface SelfMessageRecord {
66
66
  forwardId?: string
67
67
  forwardContent?: unknown[]
68
68
  sentAt: number
69
+ timestamp?: number
70
+ timestampMs?: number
69
71
  sequence: number
70
72
  source: SelfMessageSource
71
73
  kind: string
@@ -88,6 +90,8 @@ export interface SelfMessagePayload {
88
90
  forwardId?: string
89
91
  forwardContent?: unknown[]
90
92
  sentAt?: number
93
+ timestamp?: number
94
+ timestampMs?: number
91
95
  sequence?: number
92
96
  source?: SelfMessageSource
93
97
  kind?: string
@@ -0,0 +1,30 @@
1
+ import { Context } from 'koishi'
2
+ import {} from '@koishijs/plugin-server'
3
+
4
+ type KoaErrorListener = (error: Error) => void
5
+
6
+ // 仅过滤客户端断开和超大历史响应导致的已知错误。
7
+ function isIgnoredWebError(error: Error): boolean {
8
+ if (error.name === 'RangeError' && error.message === 'Invalid string length') return true
9
+ return (error as NodeJS.ErrnoException).code === 'ECONNRESET'
10
+ }
11
+
12
+ export function registerWebErrorFilter(ctx: Context) {
13
+ const app = ctx.server._koa
14
+ const defaultHandler = app.onerror
15
+ const hasDefaultHandler = app.listeners('error').includes(defaultHandler)
16
+ if (hasDefaultHandler) app.off('error', defaultHandler)
17
+
18
+ const errorHandler: KoaErrorListener = (error) => {
19
+ if (isIgnoredWebError(error)) return
20
+ if (hasDefaultHandler) defaultHandler.call(app, error)
21
+ }
22
+ app.on('error', errorHandler)
23
+
24
+ ctx.on('dispose', () => {
25
+ app.off('error', errorHandler)
26
+ if (hasDefaultHandler && !app.listeners('error').includes(defaultHandler)) {
27
+ app.on('error', defaultHandler)
28
+ }
29
+ })
30
+ }
package/src/web.ts CHANGED
@@ -15,6 +15,7 @@ import { ChatDatabase } from './database'
15
15
  import { MediaManager } from './media'
16
16
  import { PluginLogger } from './logger'
17
17
  import { ContactCacheItem, MessageRecord, SelfMessagePayload, SelfMessageRecord } from './types'
18
+ import { registerWebErrorFilter } from './web-error-filter'
18
19
 
19
20
  interface ViteConsoleServer {
20
21
  config?: {
@@ -37,11 +38,13 @@ export function registerWeb(
37
38
  media: MediaManager,
38
39
  logger: PluginLogger,
39
40
  ) {
40
- const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
41
+ registerWebErrorFilter(ctx)
42
+ const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
41
43
  const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
42
44
  const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
43
- const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
44
- const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
45
+ const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
46
+ const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
47
+ const mediaDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'media')
45
48
 
46
49
  type WebContext = Router.RouterContext<DefaultState, DefaultContext>
47
50
  const cacheType = (type: string) => type === 'user' ? 'friend' : type
@@ -106,6 +109,22 @@ export function registerWeb(
106
109
  const historyChannelCandidates = (channelId: string): string[] => {
107
110
  return channelId ? [channelId] : []
108
111
  }
112
+
113
+ const messageSortTime = (item: MessageRecord): number => {
114
+ const timestampMs = Number(item.timestampMs ?? 0)
115
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
116
+ const timestamp = Number(item.timestamp ?? 0)
117
+ if (timestamp > 0) return timestamp * 1000
118
+ return Number(item.receivedAt ?? 0)
119
+ }
120
+
121
+ const selfMessageSortTime = (item: SelfMessageRecord): number => {
122
+ const timestampMs = Number(item.timestampMs ?? item.sentAt ?? 0)
123
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
124
+ const timestamp = Number(item.timestamp ?? 0)
125
+ if (timestamp > 0) return timestamp * 1000
126
+ return item.sentAt
127
+ }
109
128
 
110
129
  const mimeToExt: Record<string, string> = {
111
130
  'audio/mpeg': '.mp3',
@@ -242,66 +261,68 @@ export function registerWeb(
242
261
  }
243
262
  })
244
263
 
245
- ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
246
- const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
247
- if (!fileName || fileName === '.' || fileName === '..') {
248
- koa.status = 400
249
- koa.body = { error: 'missing media file' }
250
- return
251
- }
252
- const filePath = path.resolve(uploadDir, fileName)
253
- const root = path.resolve(uploadDir)
254
- if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
255
- koa.status = 400
256
- koa.body = { error: 'invalid media file' }
257
- return
258
- }
259
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
260
- koa.status = 404
261
- koa.body = { error: 'media file not found' }
262
- return
263
- }
264
- const size = statSync(filePath).size
265
- koa.set('Accept-Ranges', 'bytes')
266
- if (fileName.toLowerCase().endsWith('.webm')) {
267
- koa.type = 'audio/webm'
268
- } else {
269
- koa.type = fileName
270
- }
271
- const range = koa.headers.range
272
- if (range) {
273
- const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
274
- if (!match) {
275
- koa.status = 416
276
- koa.set('Content-Range', `bytes */${size}`)
277
- koa.body = ''
278
- return
279
- }
280
- let start = 0
281
- let end = size - 1
282
- if (match[1] === '' && match[2]) {
283
- start = Math.max(0, size - Number(match[2]))
284
- } else if (match[1]) {
285
- start = Number(match[1])
286
- end = match[2] ? Number(match[2]) : end
287
- }
288
- if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
289
- koa.status = 416
290
- koa.set('Content-Range', `bytes */${size}`)
291
- koa.body = ''
292
- return
293
- }
294
- if (end >= size) end = size - 1
295
- if (end < start) end = start
296
- koa.status = 206
297
- koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
298
- koa.set('Content-Length', String(end - start + 1))
299
- koa.body = createReadStream(filePath, { start, end })
300
- return
301
- }
302
- koa.set('Content-Length', String(size))
303
- koa.body = createReadStream(filePath)
304
- })
264
+ const sendLocalFile = async (koa: WebContext, filePath: string) => {
265
+ const size = statSync(filePath).size
266
+ const fileName = path.basename(filePath)
267
+ koa.set('Accept-Ranges', 'bytes')
268
+ if (fileName.toLowerCase().endsWith('.webm')) {
269
+ koa.type = 'audio/webm'
270
+ } else {
271
+ koa.type = fileName
272
+ }
273
+ const range = koa.headers.range
274
+ if (range) {
275
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
276
+ if (!match) {
277
+ koa.status = 416
278
+ koa.set('Content-Range', `bytes */${size}`)
279
+ koa.body = ''
280
+ return
281
+ }
282
+ let start = 0
283
+ let end = size - 1
284
+ if (match[1] === '' && match[2]) {
285
+ start = Math.max(0, size - Number(match[2]))
286
+ } else if (match[1]) {
287
+ start = Number(match[1])
288
+ end = match[2] ? Number(match[2]) : end
289
+ }
290
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
291
+ koa.status = 416
292
+ koa.set('Content-Range', `bytes */${size}`)
293
+ koa.body = ''
294
+ return
295
+ }
296
+ if (end >= size) end = size - 1
297
+ if (end < start) end = start
298
+ koa.status = 206
299
+ koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
300
+ koa.set('Content-Length', String(end - start + 1))
301
+ koa.body = createReadStream(filePath, { start, end })
302
+ return
303
+ }
304
+ koa.set('Content-Length', String(size))
305
+ koa.body = createReadStream(filePath)
306
+ }
307
+
308
+ ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
309
+ const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
310
+ if (!fileName || fileName === '.' || fileName === '..') {
311
+ koa.status = 400
312
+ koa.body = { error: 'missing media file' }
313
+ return
314
+ }
315
+ for (const root of [uploadDir, mediaDir]) {
316
+ const filePath = path.resolve(root, fileName)
317
+ if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)
318
+ && existsSync(filePath) && statSync(filePath).isFile()) {
319
+ await sendLocalFile(koa, filePath)
320
+ return
321
+ }
322
+ }
323
+ koa.status = 404
324
+ koa.body = { error: 'media file not found' }
325
+ })
305
326
 
306
327
  ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
307
328
  const entries = await database.getAllContacts()
@@ -362,13 +383,13 @@ export function registerWeb(
362
383
  selfMessages = await querySelfMessages(candidate)
363
384
  if (selfMessages.length) break
364
385
  }
365
- // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
366
- messages.sort((a, b) => {
367
- const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
368
- const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
369
- return timeA - timeB
386
+ // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
387
+ messages.sort((a, b) => {
388
+ const timeA = messageSortTime(a)
389
+ const timeB = messageSortTime(b)
390
+ return timeA - timeB
370
391
  })
371
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
392
+ selfMessages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
372
393
  // 合并两条记录流后只返回 limit 条,保证前端分页/结束判断不会因自消息被放大
373
394
  const combined: Array<{
374
395
  kind: 'message' | 'self'
@@ -378,12 +399,12 @@ export function registerWeb(
378
399
  for (const item of messages) {
379
400
  combined.push({
380
401
  kind: 'message',
381
- time: Number(item?.receivedAt ?? item?.timestampMs ?? item?.timestamp ?? 0),
402
+ time: messageSortTime(item),
382
403
  record: item,
383
404
  })
384
405
  }
385
406
  for (const item of selfMessages) {
386
- combined.push({ kind: 'self', time: item.sentAt, record: item })
407
+ combined.push({ kind: 'self', time: selfMessageSortTime(item), record: item })
387
408
  }
388
409
  combined.sort((a, b) => a.time - b.time)
389
410
  const capped = combined.slice(-limit)
@@ -416,7 +437,7 @@ export function registerWeb(
416
437
  : await database.listSelfMessages(platform, selfId, candidate, limit)
417
438
  if (messages.length) break
418
439
  }
419
- messages.sort((a, b) => a.sentAt - b.sentAt)
440
+ messages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
420
441
  koa.body = { messages }
421
442
  })
422
443
 
@@ -438,9 +459,15 @@ export function registerWeb(
438
459
  message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
439
460
  forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
440
461
  forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
441
- sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
442
- ? body.sentAt
443
- : Date.now(),
462
+ sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
463
+ ? body.sentAt
464
+ : Date.now(),
465
+ timestamp: typeof body.timestamp === 'number' && Number.isFinite(body.timestamp)
466
+ ? body.timestamp
467
+ : undefined,
468
+ timestampMs: typeof body.timestampMs === 'number' && Number.isFinite(body.timestampMs)
469
+ ? body.timestampMs
470
+ : undefined,
444
471
  sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
445
472
  ? body.sequence
446
473
  : 0,
@@ -456,7 +483,8 @@ export function registerWeb(
456
483
  koa.body = { error: 'missing self-message params' }
457
484
  return
458
485
  }
459
- const record: SelfMessageRecord = {
486
+ const sentAt = payload.sentAt ?? Date.now()
487
+ const record: SelfMessageRecord = {
460
488
  id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
461
489
  platform: payload.platform,
462
490
  selfId: payload.selfId,
@@ -469,7 +497,9 @@ export function registerWeb(
469
497
  message: payload.message,
470
498
  forwardId: payload.forwardId,
471
499
  forwardContent: payload.forwardContent,
472
- sentAt: payload.sentAt ?? Date.now(),
500
+ sentAt,
501
+ timestamp: payload.timestamp ?? Math.floor(sentAt / 1000),
502
+ timestampMs: payload.timestampMs ?? sentAt,
473
503
  sequence: payload.sequence ?? 0,
474
504
  source: payload.source ?? 'webui',
475
505
  kind: payload.kind ?? 'text',