koishi-plugin-chat-patch 6.0.0 → 6.1.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.
package/lib/types.d.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { Awaitable } from 'koishi';
2
+ export interface SatoriBootstrap {
3
+ endpoint: string;
4
+ token: string;
5
+ basePath: string;
6
+ logins: SatoriLoginInfo[];
7
+ blockedPlatforms: Array<{
8
+ platformName: string;
9
+ exactMatch: boolean;
10
+ }>;
11
+ }
12
+ export interface SatoriLoginInfo {
13
+ platform: string;
14
+ selfId: string;
15
+ name: string;
16
+ avatar?: string;
17
+ status: number;
18
+ features?: string[];
19
+ }
20
+ export interface SatoriEventPayload {
21
+ type: string;
22
+ platform: string;
23
+ selfId: string;
24
+ timestamp: number;
25
+ sn: number;
26
+ body: Record<string, unknown>;
27
+ }
28
+ export interface MessageRecord {
29
+ id?: string;
30
+ sequence?: number;
31
+ type: string;
32
+ platform: string;
33
+ selfId: string;
34
+ channelId?: string;
35
+ guildId?: string;
36
+ userId?: string;
37
+ timestamp: number;
38
+ timestampMs?: number;
39
+ receivedAt?: number;
40
+ content?: string;
41
+ elements?: unknown[];
42
+ raw?: unknown;
43
+ revoked?: boolean;
44
+ revokedAt?: number;
45
+ }
46
+ export type SelfMessageChannelType = 'group' | 'user';
47
+ export type SelfMessageSource = 'webui' | 'bot' | 'plugin';
48
+ export interface SelfMessageRecord {
49
+ id: string;
50
+ platform: string;
51
+ selfId: string;
52
+ channelId: string;
53
+ guildId?: string;
54
+ channelType?: SelfMessageChannelType;
55
+ messageId?: string;
56
+ content?: string;
57
+ elements?: unknown[];
58
+ message?: unknown[];
59
+ forwardId?: string;
60
+ forwardContent?: unknown[];
61
+ sentAt: number;
62
+ timestamp?: number;
63
+ timestampMs?: number;
64
+ sequence: number;
65
+ source: SelfMessageSource;
66
+ kind: string;
67
+ fingerprint?: string;
68
+ revoked?: boolean;
69
+ revokedAt?: number;
70
+ }
71
+ export interface SelfMessagePayload {
72
+ id?: string;
73
+ platform: string;
74
+ selfId: string;
75
+ channelId: string;
76
+ guildId?: string;
77
+ channelType?: SelfMessageChannelType;
78
+ messageId?: string;
79
+ content?: string;
80
+ elements?: unknown[];
81
+ message?: unknown[];
82
+ forwardId?: string;
83
+ forwardContent?: unknown[];
84
+ sentAt?: number;
85
+ timestamp?: number;
86
+ timestampMs?: number;
87
+ sequence?: number;
88
+ source?: SelfMessageSource;
89
+ kind?: string;
90
+ revoked?: boolean;
91
+ revokedAt?: number;
92
+ }
93
+ export interface HistoryQuery {
94
+ platform: string;
95
+ selfId: string;
96
+ channelId: string;
97
+ limit?: number;
98
+ }
99
+ export interface HistoryResult {
100
+ messages: MessageRecord[];
101
+ }
102
+ export interface PinnedState {
103
+ bots: string[];
104
+ channels: string[];
105
+ }
106
+ export interface PluginConfigPayload {
107
+ basePath: string;
108
+ maxMessagesPerChannel: number;
109
+ historyPageSize: number;
110
+ maxMediaFiles: number;
111
+ blockedPlatforms: Array<{
112
+ platformName: string;
113
+ exactMatch: boolean;
114
+ }>;
115
+ loggerinfo: boolean;
116
+ }
117
+ export interface ContactCacheItem {
118
+ id: string;
119
+ name: string;
120
+ avatar?: string;
121
+ channelId?: string;
122
+ guildId?: string;
123
+ raw?: unknown;
124
+ }
125
+ export interface ContactCacheQuery {
126
+ platform: string;
127
+ selfId: string;
128
+ type: string;
129
+ contacts?: ContactCacheItem[];
130
+ append?: boolean;
131
+ }
132
+ export interface ContactCacheResult {
133
+ contacts?: ContactCacheItem[];
134
+ }
135
+ declare module '@koishijs/console' {
136
+ interface Events {
137
+ 'chat-patch/bootstrap'(): SatoriBootstrap;
138
+ 'chat-patch/history'(query: HistoryQuery): Awaitable<HistoryResult>;
139
+ 'chat-patch/config'(): PluginConfigPayload;
140
+ 'chat-patch/contact-cache'(query: ContactCacheQuery): Awaitable<ContactCacheResult>;
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-patch",
3
3
  "description": "[<ruby>chat-patch<rp>(</rp><rt>点我预览效果</rt><rp>)</rp></ruby>](https://i0.hdslb.com/bfs/openplatform/71074dfc9e5256fc3333d8bd8478bec1af874046.png) 视奸小插件((bushi( (低性能警告)。手机端适配。灵感来自 chat 插件。",
4
- "version": "6.0.0",
4
+ "version": "6.1.0",
5
5
  "scripts": {
6
6
  "build:web": "npm --prefix client/web run build",
7
7
  "prepack": "npm run build:web",
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
package/src/web.ts CHANGED
@@ -40,8 +40,9 @@ export function registerWeb(
40
40
  const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
41
41
  const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
42
42
  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')
43
+ const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
44
+ const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
45
+ const mediaDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'media')
45
46
 
46
47
  type WebContext = Router.RouterContext<DefaultState, DefaultContext>
47
48
  const cacheType = (type: string) => type === 'user' ? 'friend' : type
@@ -106,6 +107,22 @@ export function registerWeb(
106
107
  const historyChannelCandidates = (channelId: string): string[] => {
107
108
  return channelId ? [channelId] : []
108
109
  }
110
+
111
+ const messageSortTime = (item: MessageRecord): number => {
112
+ const timestampMs = Number(item.timestampMs ?? 0)
113
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
114
+ const timestamp = Number(item.timestamp ?? 0)
115
+ if (timestamp > 0) return timestamp * 1000
116
+ return Number(item.receivedAt ?? 0)
117
+ }
118
+
119
+ const selfMessageSortTime = (item: SelfMessageRecord): number => {
120
+ const timestampMs = Number(item.timestampMs ?? item.sentAt ?? 0)
121
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
122
+ const timestamp = Number(item.timestamp ?? 0)
123
+ if (timestamp > 0) return timestamp * 1000
124
+ return item.sentAt
125
+ }
109
126
 
110
127
  const mimeToExt: Record<string, string> = {
111
128
  'audio/mpeg': '.mp3',
@@ -242,66 +259,68 @@ export function registerWeb(
242
259
  }
243
260
  })
244
261
 
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
- })
262
+ const sendLocalFile = async (koa: WebContext, filePath: string) => {
263
+ const size = statSync(filePath).size
264
+ const fileName = path.basename(filePath)
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
+ }
305
+
306
+ ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
307
+ const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
308
+ if (!fileName || fileName === '.' || fileName === '..') {
309
+ koa.status = 400
310
+ koa.body = { error: 'missing media file' }
311
+ return
312
+ }
313
+ for (const root of [uploadDir, mediaDir]) {
314
+ const filePath = path.resolve(root, fileName)
315
+ if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)
316
+ && existsSync(filePath) && statSync(filePath).isFile()) {
317
+ await sendLocalFile(koa, filePath)
318
+ return
319
+ }
320
+ }
321
+ koa.status = 404
322
+ koa.body = { error: 'media file not found' }
323
+ })
305
324
 
306
325
  ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
307
326
  const entries = await database.getAllContacts()
@@ -362,13 +381,13 @@ export function registerWeb(
362
381
  selfMessages = await querySelfMessages(candidate)
363
382
  if (selfMessages.length) break
364
383
  }
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
384
+ // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
385
+ messages.sort((a, b) => {
386
+ const timeA = messageSortTime(a)
387
+ const timeB = messageSortTime(b)
388
+ return timeA - timeB
370
389
  })
371
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
390
+ selfMessages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
372
391
  // 合并两条记录流后只返回 limit 条,保证前端分页/结束判断不会因自消息被放大
373
392
  const combined: Array<{
374
393
  kind: 'message' | 'self'
@@ -378,12 +397,12 @@ export function registerWeb(
378
397
  for (const item of messages) {
379
398
  combined.push({
380
399
  kind: 'message',
381
- time: Number(item?.receivedAt ?? item?.timestampMs ?? item?.timestamp ?? 0),
400
+ time: messageSortTime(item),
382
401
  record: item,
383
402
  })
384
403
  }
385
404
  for (const item of selfMessages) {
386
- combined.push({ kind: 'self', time: item.sentAt, record: item })
405
+ combined.push({ kind: 'self', time: selfMessageSortTime(item), record: item })
387
406
  }
388
407
  combined.sort((a, b) => a.time - b.time)
389
408
  const capped = combined.slice(-limit)
@@ -416,7 +435,7 @@ export function registerWeb(
416
435
  : await database.listSelfMessages(platform, selfId, candidate, limit)
417
436
  if (messages.length) break
418
437
  }
419
- messages.sort((a, b) => a.sentAt - b.sentAt)
438
+ messages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
420
439
  koa.body = { messages }
421
440
  })
422
441
 
@@ -438,9 +457,15 @@ export function registerWeb(
438
457
  message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
439
458
  forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
440
459
  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(),
460
+ sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
461
+ ? body.sentAt
462
+ : Date.now(),
463
+ timestamp: typeof body.timestamp === 'number' && Number.isFinite(body.timestamp)
464
+ ? body.timestamp
465
+ : undefined,
466
+ timestampMs: typeof body.timestampMs === 'number' && Number.isFinite(body.timestampMs)
467
+ ? body.timestampMs
468
+ : undefined,
444
469
  sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
445
470
  ? body.sequence
446
471
  : 0,
@@ -456,7 +481,8 @@ export function registerWeb(
456
481
  koa.body = { error: 'missing self-message params' }
457
482
  return
458
483
  }
459
- const record: SelfMessageRecord = {
484
+ const sentAt = payload.sentAt ?? Date.now()
485
+ const record: SelfMessageRecord = {
460
486
  id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
461
487
  platform: payload.platform,
462
488
  selfId: payload.selfId,
@@ -469,7 +495,9 @@ export function registerWeb(
469
495
  message: payload.message,
470
496
  forwardId: payload.forwardId,
471
497
  forwardContent: payload.forwardContent,
472
- sentAt: payload.sentAt ?? Date.now(),
498
+ sentAt,
499
+ timestamp: payload.timestamp ?? Math.floor(sentAt / 1000),
500
+ timestampMs: payload.timestampMs ?? sentAt,
473
501
  sequence: payload.sequence ?? 0,
474
502
  source: payload.source ?? 'webui',
475
503
  kind: payload.kind ?? 'text',