koishi-plugin-chat-patch 5.6.1 → 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.
Files changed (40) hide show
  1. package/client/web/dist/assets/Chat-BLcuHxzh.js +28 -0
  2. package/client/web/dist/assets/{Chat-BXVWj5Bd.css → Chat-Cee07eDL.css} +1 -1
  3. package/client/web/dist/assets/{MsgBody-CzrxyiFO.js → MsgBody-krZlLzRL.js} +1 -1
  4. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CLmUjKws.js +67 -0
  5. package/client/web/dist/assets/{index-EJ2CcODr.js → index-DOJoilEy.js} +57 -60
  6. package/client/web/dist/index.html +1 -1
  7. package/client/web/src/assets/l10n/zh-CN.po +0 -3
  8. package/client/web/src/components/MsgBody.vue +29 -20
  9. package/client/web/src/function/connect.ts +17 -18
  10. package/client/web/src/function/msg.ts +2 -0
  11. package/client/web/src/function/satori-model.ts +2 -0
  12. package/client/web/src/function/satori.ts +18 -12
  13. package/client/web/src/function/utils/appUtil.ts +27 -6
  14. package/client/web/src/function/utils/msgUtil.ts +27 -21
  15. package/client/web/src/pages/Chat.vue +35 -32
  16. package/lib/database.d.ts +5 -3
  17. package/lib/gateway.d.ts +2 -4
  18. package/lib/index.d.ts +17 -0
  19. package/lib/index.js +5409 -473
  20. package/lib/media.d.ts +1 -1
  21. package/lib/recorder.d.ts +1 -4
  22. package/lib/self-message.d.ts +1 -4
  23. package/lib/types.d.ts +4 -0
  24. package/package.json +25 -10
  25. package/src/bootstrap.ts +3 -2
  26. package/src/config.ts +1 -5
  27. package/src/database.ts +313 -139
  28. package/src/gateway.ts +21 -13
  29. package/src/index.ts +59 -54
  30. package/src/media.ts +43 -12
  31. package/src/recorder.ts +2 -12
  32. package/src/self-message.ts +7 -14
  33. package/src/server.d.ts +17 -3
  34. package/src/types.ts +4 -0
  35. package/src/web.ts +245 -151
  36. package/client/web/dist/assets/Chat-m4-U5uHa.js +0 -28
  37. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +0 -67
  38. package/lib/bootstrap.d.ts +0 -6
  39. package/lib/satori.d.ts +0 -3
  40. package/lib/web.d.ts +0 -7
package/src/database.ts CHANGED
@@ -1,17 +1,23 @@
1
- import { Level } from 'level'
2
- import { Context } from 'koishi'
3
- import path from 'node:path'
1
+ import { Context } from 'koishi'
2
+ import { mkdirSync } from 'node:fs'
3
+ import path from 'node:path'
4
4
  import { createHash } from 'node:crypto'
5
5
 
6
6
  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,8 +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
- const time = String(record.timestamp).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')
32
41
  return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
33
42
  }
34
43
 
@@ -37,8 +46,10 @@ function selfMessagePrefix(platform: string, selfId: string, channelId: string):
37
46
  return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
38
47
  }
39
48
 
40
- function selfMessageKey(record: SelfMessageRecord): string {
41
- 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')
42
53
  return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
43
54
  }
44
55
 
@@ -73,130 +84,240 @@ function isUsableGroupContact(item: ContactCacheItem): boolean {
73
84
  return true
74
85
  }
75
86
 
76
- type CompactableLevel = Level<string, string> & {
87
+ type KvWriteOperation =
88
+ | { type: 'put'; key: string; value: string }
89
+ | { type: 'del'; key: string }
90
+
91
+ interface KvIteratorOptions {
92
+ gte?: string
93
+ lte?: string
94
+ lt?: string
95
+ reverse?: boolean
96
+ limit?: number
97
+ }
98
+
99
+ interface LevelIteratorOptions {
100
+ gte?: string
101
+ lte?: string
102
+ lt?: string
103
+ reverse?: boolean
104
+ limit?: number
105
+ }
106
+
107
+ interface LevelDatabase {
108
+ open(): Promise<void>
109
+ close(): Promise<void>
110
+ put(key: string, value: string): Promise<void>
111
+ get(key: string): Promise<string>
112
+ del(key: string): Promise<void>
113
+ batch(operations: KvWriteOperation[]): Promise<void>
114
+ clear(): Promise<void>
115
+ iterator<K, V>(options?: LevelIteratorOptions): AsyncIterable<[K, V]>
77
116
  compactRange(start: string, end: string): Promise<void>
78
117
  }
79
118
 
80
- interface SharedDatabase {
81
- dir: string
82
- db: Level<string, string>
83
- refs: number
84
- opened: boolean
85
- opening?: Promise<void>
86
- closing?: Promise<void>
119
+ interface LevelConstructor {
120
+ new (
121
+ location: string,
122
+ options?: { keyEncoding?: string; valueEncoding?: string },
123
+ ): LevelDatabase
124
+ }
125
+
126
+ interface LevelModule {
127
+ Level: LevelConstructor
128
+ }
129
+
130
+ interface WNodeService {
131
+ import<T>(packageName: string, options?: {
132
+ allowInstall?: boolean
133
+ useRequire?: boolean
134
+ version?: string
135
+ }): Promise<T>
87
136
  }
137
+
138
+ interface KvStore {
139
+ readonly path: string
140
+ readonly driver: 'level'
141
+ readonly isOpen: boolean
142
+ put(key: string, value: string): Promise<void>
143
+ get(key: string): Promise<string>
144
+ del(key: string): Promise<void>
145
+ batch(operations: KvWriteOperation[]): Promise<void>
146
+ clear(): Promise<void>
147
+ vacuum(): Promise<void>
148
+ iterator(options?: KvIteratorOptions): AsyncGenerator<[string, string]>
149
+ close(): Promise<void> | void
150
+ }
151
+
152
+ // LevelDB 的读写性能适合这种消息缓存,恢复为之前的存储后端
153
+ class LevelKV implements KvStore {
154
+ readonly driver = 'level' as const
155
+ private opened = false
88
156
 
89
- const sharedDatabases = new Map<string, SharedDatabase>()
157
+ constructor(
158
+ readonly path: string,
159
+ private readonly level: LevelDatabase,
160
+ ) {}
90
161
 
91
- // 同目录复用同一个 LevelDB 实例,避免 HMR 卸载/重载期间新旧插件抢占 LOCK
92
- async function acquireSharedDatabase(dir: string): Promise<SharedDatabase> {
93
- let shared = sharedDatabases.get(dir)
94
- if (shared?.closing) {
95
- await shared.closing.catch(() => undefined)
96
- shared = sharedDatabases.get(dir)
162
+ get isOpen(): boolean {
163
+ return this.opened
97
164
  }
98
- if (!shared) {
99
- shared = {
100
- dir,
101
- db: new Level<string, string>(dir, { keyEncoding: 'utf8', valueEncoding: 'utf8' }),
102
- refs: 0,
103
- opened: false,
104
- }
105
- sharedDatabases.set(dir, shared)
165
+
166
+ async open() {
167
+ await this.level.open()
168
+ this.opened = true
106
169
  }
107
- shared.refs += 1
108
- return shared
109
- }
110
170
 
111
- async function releaseSharedDatabase(shared: SharedDatabase): Promise<boolean> {
112
- shared.refs -= 1
113
- if (shared.refs > 0) return false
114
- if (shared.closing) {
115
- await shared.closing
116
- return true
171
+ async close() {
172
+ if (!this.opened) return
173
+ await this.level.close()
174
+ this.opened = false
117
175
  }
118
- if (!shared.opened) {
119
- if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
120
- return true
176
+
177
+ async put(key: string, value: string) {
178
+ await this.level.put(key, value)
121
179
  }
122
- const closing = shared.db.close().catch(() => undefined).finally(() => {
123
- shared.opened = false
124
- if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
125
- })
126
- shared.closing = closing
127
- await closing
128
- return true
129
- }
130
180
 
131
- export class ChatDatabase {
132
- private readonly dir: string
133
- private shared?: SharedDatabase
181
+ async get(key: string): Promise<string> {
182
+ return await this.level.get(key)
183
+ }
134
184
 
135
- constructor(
136
- private ctx: Context,
137
- private config: Config,
138
- private logger: PluginLogger,
139
- ) {
140
- this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
185
+ async del(key: string) {
186
+ await this.level.del(key)
141
187
  }
142
188
 
143
- private get db(): Level<string, string> {
144
- if (!this.shared) throw new Error('LevelDB is not initialized')
145
- return this.shared.db
189
+ async batch(operations: KvWriteOperation[]) {
190
+ await this.level.batch(operations)
146
191
  }
147
192
 
148
- async initialize() {
149
- this.shared = await acquireSharedDatabase(this.dir)
150
- try {
151
- await this.ensureOpen()
152
- this.logger.logInfo('LevelDB 已打开:', this.shared.db.location)
153
- } catch (error) {
154
- await this.releaseShared()
155
- throw error
156
- }
193
+ async clear() {
194
+ await this.level.clear()
157
195
  }
158
196
 
159
- async dispose() {
160
- const closed = await this.releaseShared()
161
- if (closed) this.logger.logInfo('LevelDB closed')
197
+ async vacuum() {
198
+ await this.level.compactRange('', '\uffff')
162
199
  }
163
200
 
164
- private async ensureOpen() {
165
- const shared = this.shared
166
- if (!shared || shared.opened) return
167
- if (shared.opening) {
168
- await shared.opening
169
- return
201
+ async *iterator(options: KvIteratorOptions = {}): AsyncGenerator<[string, string]> {
202
+ for await (const [key, value] of this.level.iterator<string, string>(options)) {
203
+ yield [key, value]
170
204
  }
171
- const opening = shared.db.open().then(() => {
172
- shared.opened = true
205
+ }
206
+ }
207
+
208
+ interface SharedDatabase {
209
+ path: string
210
+ db: KvStore
211
+ refs: number
212
+ closing?: Promise<void>
213
+ }
214
+
215
+ const sharedDatabases = new Map<string, SharedDatabase>()
216
+
217
+ // 同文件复用同一个 LevelDB 连接,避免 HMR 卸载/重载期间互相持有旧句柄
218
+ async function acquireSharedDatabase(filePath: string, Level: LevelConstructor): Promise<SharedDatabase> {
219
+ let shared = sharedDatabases.get(filePath)
220
+ if (shared?.closing) {
221
+ await shared.closing.catch(() => undefined)
222
+ shared = sharedDatabases.get(filePath)
223
+ }
224
+ if (!shared) {
225
+ mkdirSync(path.dirname(filePath), { recursive: true })
226
+ const level = new Level(filePath, {
227
+ keyEncoding: 'utf8',
228
+ valueEncoding: 'utf8',
173
229
  })
174
- shared.opening = opening
175
- try {
176
- await opening
177
- } finally {
178
- shared.opening = undefined
179
- }
230
+ const db = new LevelKV(filePath, level)
231
+ await db.open()
232
+ shared = {
233
+ path: filePath,
234
+ db,
235
+ refs: 0,
236
+ }
237
+ sharedDatabases.set(filePath, shared)
238
+ }
239
+ shared.refs += 1
240
+ return shared
241
+ }
242
+
243
+ async function releaseSharedDatabase(shared: SharedDatabase): Promise<boolean> {
244
+ shared.refs -= 1
245
+ if (shared.refs > 0) return false
246
+ if (shared.closing) {
247
+ await shared.closing
248
+ return true
249
+ }
250
+ const closing = Promise.resolve().then(() => {
251
+ if (shared.db.isOpen) return shared.db.close()
252
+ }).catch(() => undefined).finally(() => {
253
+ if (sharedDatabases.get(shared.path) === shared) sharedDatabases.delete(shared.path)
254
+ })
255
+ shared.closing = closing
256
+ await closing
257
+ return true
258
+ }
259
+
260
+ export class ChatDatabase {
261
+ private readonly dir: string
262
+ private shared?: SharedDatabase
263
+
264
+ constructor(
265
+ private ctx: Context,
266
+ private config: Config,
267
+ private logger: PluginLogger,
268
+ ) {
269
+ this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
270
+ }
271
+
272
+ private get db(): KvStore {
273
+ if (!this.shared) throw new Error('Store is not initialized')
274
+ return this.shared.db
275
+ }
276
+
277
+ async initialize() {
278
+ const levelModule = await this.loadLevelModule()
279
+ this.shared = await acquireSharedDatabase(this.dir, levelModule.Level)
280
+ this.logger.logInfo('LevelDB 已打开:', this.shared.db.driver, this.shared.path)
180
281
  }
181
282
 
182
- private async releaseShared(): Promise<boolean> {
183
- const shared = this.shared
184
- this.shared = undefined
185
- if (shared) return releaseSharedDatabase(shared)
186
- return false
283
+ // 通过 w-node 服务动态安装并加载 level,原生 .node 不会进入插件依赖目录
284
+ private async loadLevelModule(): Promise<LevelModule> {
285
+ const nodeService = this.ctx.node
286
+ if (!nodeService) {
287
+ throw new Error('未检测到 w-node 服务,请先安装 koishi-plugin-w-node')
288
+ }
289
+ const levelModule = await nodeService.import<LevelModule>('level', {
290
+ version: '^10.0.0',
291
+ useRequire: true,
292
+ })
293
+ if (!levelModule?.Level) {
294
+ throw new Error('w-node 动态加载 level 失败')
295
+ }
296
+ return levelModule
187
297
  }
188
298
 
299
+ async dispose() {
300
+ const closed = await this.releaseShared()
301
+ if (closed) this.logger.logInfo('存储已关闭')
302
+ }
303
+
304
+ private async releaseShared(): Promise<boolean> {
305
+ const shared = this.shared
306
+ this.shared = undefined
307
+ if (shared) return releaseSharedDatabase(shared)
308
+ return false
309
+ }
310
+
189
311
  async clearAll() {
190
- // 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
312
+ // 先清空记录,再强制压缩,让旧页也能被回收
191
313
  await this.db.clear()
192
- const db = this.db as unknown as CompactableLevel
193
- await db.compactRange('', '\uffff')
314
+ await this.db.vacuum()
194
315
  this.logger.logInfo('数据库缓存已全部清空并完成压缩')
195
316
  }
196
317
 
197
318
  async appendMessage(record: MessageRecord) {
198
319
  await this.db.put(messageKey(record), JSON.stringify(record))
199
- await this.trimMessages(record.platform, record.selfId, record.channelId || '')
320
+ await this.trimChannel(record.platform, record.selfId, record.channelId || '')
200
321
  }
201
322
 
202
323
  async upsertSelfMessage(record: SelfMessageRecord) {
@@ -210,7 +331,7 @@ export class ChatDatabase {
210
331
  )
211
332
  }
212
333
  await this.db.put(selfMessageKey(record), JSON.stringify(record))
213
- await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
334
+ await this.trimChannel(record.platform, record.selfId, record.channelId)
214
335
  }
215
336
 
216
337
  async listSelfMessages(
@@ -221,7 +342,7 @@ export class ChatDatabase {
221
342
  ): Promise<SelfMessageRecord[]> {
222
343
  const result: SelfMessageRecord[] = []
223
344
  const prefix = selfMessagePrefix(platform, selfId, channelId)
224
- for await (const [, value] of this.db.iterator<string, string>({
345
+ for await (const [, value] of this.db.iterator({
225
346
  gte: prefix,
226
347
  lte: `${prefix}\uffff`,
227
348
  reverse: true,
@@ -243,7 +364,7 @@ export class ChatDatabase {
243
364
  const result: SelfMessageRecord[] = []
244
365
  const prefix = selfMessagePrefix(platform, selfId, channelId)
245
366
  const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
246
- for await (const [, value] of this.db.iterator<string, string>({
367
+ for await (const [, value] of this.db.iterator({
247
368
  gte: prefix,
248
369
  lt: before,
249
370
  reverse: true,
@@ -263,7 +384,7 @@ export class ChatDatabase {
263
384
  patch: Partial<SelfMessageRecord>,
264
385
  ): Promise<boolean> {
265
386
  const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
266
- for await (const [key, value] of this.db.iterator<string, string>({
387
+ for await (const [key, value] of this.db.iterator({
267
388
  gte: prefix,
268
389
  lte: `${prefix}\uffff`,
269
390
  })) {
@@ -290,7 +411,7 @@ export class ChatDatabase {
290
411
  ): Promise<boolean> {
291
412
  const operations: Array<{ type: 'put'; key: string; value: string }> = []
292
413
  for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
293
- for await (const [key, value] of this.db.iterator<string, string>({
414
+ for await (const [key, value] of this.db.iterator({
294
415
  gte: prefix,
295
416
  lte: `${prefix}\uffff`,
296
417
  })) {
@@ -330,7 +451,7 @@ export class ChatDatabase {
330
451
  ? [selfMessagePrefix(platform, selfId, channelId)]
331
452
  : [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
332
453
  for (const prefix of prefixes) {
333
- for await (const [, value] of this.db.iterator<string, string>({
454
+ for await (const [, value] of this.db.iterator({
334
455
  gte: prefix,
335
456
  lte: `${prefix}\uffff`,
336
457
  })) {
@@ -355,7 +476,7 @@ export class ChatDatabase {
355
476
  ) {
356
477
  const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
357
478
  const toDelete: string[] = []
358
- for await (const [key, value] of this.db.iterator<string, string>({
479
+ for await (const [key, value] of this.db.iterator({
359
480
  gte: prefix,
360
481
  lte: `${prefix}\uffff`,
361
482
  })) {
@@ -396,7 +517,7 @@ export class ChatDatabase {
396
517
  ): Promise<MessageRecord[]> {
397
518
  const result: MessageRecord[] = []
398
519
  for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
399
- for await (const [, value] of this.db.iterator<string, string>({
520
+ for await (const [, value] of this.db.iterator({
400
521
  gte: prefix,
401
522
  lte: `${prefix}\uffff`,
402
523
  reverse: true,
@@ -426,7 +547,7 @@ export class ChatDatabase {
426
547
  const result: MessageRecord[] = []
427
548
  for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
428
549
  const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
429
- for await (const [, value] of this.db.iterator<string, string>({
550
+ for await (const [, value] of this.db.iterator({
430
551
  gte: prefix,
431
552
  lt: before,
432
553
  reverse: true,
@@ -449,7 +570,7 @@ export class ChatDatabase {
449
570
  async clearChannel(platform: string, selfId: string, channelId: string) {
450
571
  const operations: Array<{ type: 'del'; key: string }> = []
451
572
  for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
452
- for await (const [key] of this.db.iterator<string, string>({
573
+ for await (const [key] of this.db.iterator({
453
574
  gte: prefix,
454
575
  lte: `${prefix}\uffff`,
455
576
  })) {
@@ -457,7 +578,7 @@ export class ChatDatabase {
457
578
  }
458
579
  }
459
580
  const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
460
- for await (const [key] of this.db.iterator<string, string>({
581
+ for await (const [key] of this.db.iterator({
461
582
  gte: selfPrefix,
462
583
  lte: `${selfPrefix}\uffff`,
463
584
  })) {
@@ -521,7 +642,7 @@ export class ChatDatabase {
521
642
 
522
643
  async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
523
644
  const result: Array<{ filePath: string; channelId: string }> = []
524
- for await (const [, value] of this.db.iterator<string, string>({
645
+ for await (const [, value] of this.db.iterator({
525
646
  gte: 'media:',
526
647
  lte: 'media:\uffff',
527
648
  })) {
@@ -548,7 +669,7 @@ export class ChatDatabase {
548
669
  async removeMediaByPath(filePath: string) {
549
670
  const normalized = path.normalize(filePath)
550
671
  const toDelete: string[] = []
551
- for await (const [key, value] of this.db.iterator<string, string>({
672
+ for await (const [key, value] of this.db.iterator({
552
673
  gte: 'media:',
553
674
  lte: 'media:\uffff',
554
675
  })) {
@@ -690,7 +811,7 @@ export class ChatDatabase {
690
811
  kind: 'new' | 'legacy'
691
812
  entry: ContactEntry
692
813
  }>()
693
- for await (const [key, value] of this.db.iterator<string, string>({
814
+ for await (const [key, value] of this.db.iterator({
694
815
  gte: 'c:',
695
816
  lte: 'c:\uffff',
696
817
  })) {
@@ -742,36 +863,89 @@ export class ChatDatabase {
742
863
  return result
743
864
  }
744
865
 
745
- private async trimMessages(platform: string, selfId: string, channelId: string) {
746
- let count = 0
747
- const toDelete: string[] = []
748
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
749
- for await (const [key] of this.db.iterator<string, string>({
866
+ // 收到消息或机器人发送消息后统一裁剪,m sm 合起来按时间保留上限
867
+ private async trimChannel(platform: string, selfId: string, channelId: string) {
868
+ const rows: Array<{ key: string; time: number }> = []
869
+ const prefixes = [
870
+ messagePrefix(platform, selfId, channelId),
871
+ legacyMessagePrefix(platform, selfId, channelId),
872
+ selfMessagePrefix(platform, selfId, channelId),
873
+ ]
874
+ for (const prefix of prefixes) {
875
+ for await (const [key, value] of this.db.iterator({
750
876
  gte: prefix,
751
877
  lte: `${prefix}\uffff`,
752
878
  })) {
753
- count += 1
754
- if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
879
+ rows.push({ key, time: this.extractRecordTime(value) })
755
880
  }
756
881
  }
757
- if (!toDelete.length) return
758
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
759
- this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
882
+ await this.trimRows(rows)
760
883
  }
761
884
 
762
- private async trimSelfMessages(platform: string, selfId: string, channelId: string) {
763
- let count = 0
764
- const toDelete: string[] = []
765
- const prefix = selfMessagePrefix(platform, selfId, channelId)
766
- for await (const [key] of this.db.iterator<string, string>({
767
- gte: prefix,
768
- lte: `${prefix}\uffff`,
769
- })) {
770
- count += 1
771
- if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
885
+ // 启动时清理历史遗留的超量消息,避免配置项只在写入时才生效
886
+ async cleanupExcess() {
887
+ const groups = new Map<string, {
888
+ platform: string
889
+ selfId: string
890
+ channelId: string
891
+ rows: Array<{ key: string; time: number }>
892
+ }>()
893
+ const collect = async (prefix: string) => {
894
+ for await (const [key, value] of this.db.iterator({
895
+ gte: prefix,
896
+ lte: `${prefix}\uffff`,
897
+ })) {
898
+ let parsed: Record<string, unknown>
899
+ try {
900
+ parsed = JSON.parse(value) as Record<string, unknown>
901
+ } catch {
902
+ continue
903
+ }
904
+ const platform = typeof parsed.platform === 'string' ? parsed.platform : ''
905
+ const selfId = typeof parsed.selfId === 'string' ? parsed.selfId : ''
906
+ const channelId = typeof parsed.channelId === 'string' ? parsed.channelId : ''
907
+ if (!platform || !selfId) continue
908
+ const groupKey = JSON.stringify([platform, selfId, channelId])
909
+ let group = groups.get(groupKey)
910
+ if (!group) {
911
+ group = { platform, selfId, channelId, rows: [] }
912
+ groups.set(groupKey, group)
913
+ }
914
+ group.rows.push({ key, time: this.extractRecordTime(value) })
915
+ }
772
916
  }
773
- if (!toDelete.length) return
774
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
775
- this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
917
+ await collect('m:')
918
+ await collect('sm:')
919
+
920
+ let removed = 0
921
+ for (const group of groups.values()) {
922
+ const excess = group.rows.length - this.config.maxMessagesPerChannel
923
+ if (excess <= 0) continue
924
+ group.rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
925
+ const keys = group.rows.slice(0, excess).map((item) => item.key)
926
+ await this.db.batch(keys.map((key) => ({ type: 'del', key })))
927
+ removed += keys.length
928
+ }
929
+ if (removed) {
930
+ this.logger.logInfo(`启动时已裁剪 ${removed} 条超出上限的历史消息`)
931
+ }
932
+ }
933
+
934
+ private async trimRows(rows: Array<{ key: string; time: number }>) {
935
+ const excess = rows.length - this.config.maxMessagesPerChannel
936
+ if (excess <= 0) return
937
+ rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
938
+ const keys = rows.slice(0, excess).map((item) => item.key)
939
+ await this.db.batch(keys.map((key) => ({ type: 'del', key })))
940
+ this.logger.logInfo(`频道历史已裁剪 ${keys.length} 条`)
941
+ }
942
+
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
+ }
776
950
  }
777
951
  }