koishi-plugin-chat-patch 5.5.0 → 5.6.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 (44) hide show
  1. package/client/vue/index.vue +8 -1
  2. package/client/web/dist/assets/{Chat-DTEsebnY.css → Chat-BXVWj5Bd.css} +1 -1
  3. package/client/web/dist/assets/{Chat-Clqxn7CB.js → Chat-m4-U5uHa.js} +4 -4
  4. package/client/web/dist/assets/{MsgBody-C0TKsXhC.js → MsgBody-CzrxyiFO.js} +1 -1
  5. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +67 -0
  6. package/client/web/dist/assets/{index-BA37EQGa.js → index-EJ2CcODr.js} +72 -78
  7. package/client/web/dist/assets/index-X9hFOAza.css +1 -0
  8. package/client/web/dist/index.html +2 -2
  9. package/client/web/src/App.vue +7 -0
  10. package/client/web/src/assets/css/chat.css +6 -4
  11. package/client/web/src/assets/l10n/zh-CN.po +0 -6
  12. package/client/web/src/components/History.vue +27 -0
  13. package/client/web/src/components/MsgBody.vue +18 -9
  14. package/client/web/src/function/connect.ts +208 -46
  15. package/client/web/src/function/msg.ts +8 -6
  16. package/client/web/src/function/option.ts +1 -1
  17. package/client/web/src/function/satori-model.ts +20 -2
  18. package/client/web/src/function/satori.ts +64 -0
  19. package/client/web/src/pages/Chat.vue +0 -4
  20. package/client/web/src/pages/Friends.vue +7 -9
  21. package/client/web/src/pages/Messages.vue +6 -7
  22. package/client/web/src/pages/options/OptDev.vue +1 -25
  23. package/client/web/src/pages/options/OptFunction.vue +23 -0
  24. package/client/web/tsconfig.tsbuildinfo +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/style.css +1 -1
  27. package/lib/bootstrap.d.ts +6 -0
  28. package/lib/database.d.ts +5 -2
  29. package/lib/gateway.d.ts +46 -0
  30. package/lib/index.js +1468 -131
  31. package/lib/recorder.d.ts +3 -5
  32. package/lib/satori.d.ts +3 -0
  33. package/lib/types.d.ts +17 -0
  34. package/package.json +4 -1
  35. package/src/bootstrap.ts +5 -10
  36. package/src/database.ts +745 -662
  37. package/src/gateway.ts +259 -0
  38. package/src/index.ts +63 -53
  39. package/src/recorder.ts +97 -77
  40. package/src/satori.ts +19 -0
  41. package/src/server.d.ts +26 -26
  42. package/src/types.ts +19 -0
  43. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CCdBwlN_.js +0 -67
  44. package/client/web/dist/assets/index-B71pPwae.css +0 -1
package/src/database.ts CHANGED
@@ -1,694 +1,777 @@
1
- import { Level } from 'level'
2
- import { Context } from 'koishi'
3
- import path from 'node:path'
4
- import { createHash } from 'node:crypto'
5
-
6
- import { Config } from './config'
7
- import { ContactCacheItem, MessageRecord, PinnedState, SelfMessageRecord } from './types'
8
- import { PluginLogger } from './logger'
9
-
10
- function encodeKeyPart(value: string): string {
11
- return encodeURIComponent(value)
12
- }
13
-
14
- function decodeKeyPart(value: string): string {
15
- try {
16
- return decodeURIComponent(value)
17
- } catch {
18
- return value
19
- }
20
- }
21
-
22
- function messagePrefix(platform: string, selfId: string, channelId: string): string {
23
- return `m:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
24
- }
25
-
26
- function legacyMessagePrefix(platform: string, selfId: string, channelId: string): string {
27
- return `m:${platform}:${selfId}:${channelId}:`
28
- }
29
-
30
- function messageKey(record: MessageRecord): string {
31
- const time = String(record.timestamp).padStart(16, '0')
32
- return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
33
- }
34
-
35
- // 独立命名空间保存机器人自身消息,避免和收到的用户消息混用
36
- function selfMessagePrefix(platform: string, selfId: string, channelId: string): string {
37
- return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
38
- }
39
-
40
- function selfMessageKey(record: SelfMessageRecord): string {
41
- const time = String(record.sentAt).padStart(16, '0')
42
- return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
43
- }
44
-
45
- function contactKey(platform: string, selfId: string, type: string): string {
46
- return `c:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(type)}`
47
- }
48
-
49
- function legacyContactKey(platform: string, selfId: string, type: string): string {
50
- return `c:${platform}:${selfId}:${type}`
51
- }
52
-
53
- function groupMemberKey(platform: string, selfId: string, groupId: string): string {
54
- return `gm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(groupId)}`
55
- }
56
-
57
- function legacyGroupMemberKey(platform: string, selfId: string, groupId: string): string {
58
- return `gm:${platform}:${selfId}:${groupId}`
1
+ import { Level } from 'level'
2
+ import { Context } from 'koishi'
3
+ import path from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ import { Config } from './config'
7
+ import { ContactCacheItem, MessageRecord, PinnedState, SelfMessageRecord } from './types'
8
+ import { PluginLogger } from './logger'
9
+
10
+ function encodeKeyPart(value: string): string {
11
+ return encodeURIComponent(value)
12
+ }
13
+
14
+ function decodeKeyPart(value: string): string {
15
+ try {
16
+ return decodeURIComponent(value)
17
+ } catch {
18
+ return value
19
+ }
20
+ }
21
+
22
+ function messagePrefix(platform: string, selfId: string, channelId: string): string {
23
+ return `m:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
24
+ }
25
+
26
+ function legacyMessagePrefix(platform: string, selfId: string, channelId: string): string {
27
+ return `m:${platform}:${selfId}:${channelId}:`
28
+ }
29
+
30
+ function messageKey(record: MessageRecord): string {
31
+ const time = String(record.timestamp).padStart(16, '0')
32
+ return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
33
+ }
34
+
35
+ // 独立命名空间保存机器人自身消息,避免和收到的用户消息混用
36
+ function selfMessagePrefix(platform: string, selfId: string, channelId: string): string {
37
+ return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
38
+ }
39
+
40
+ function selfMessageKey(record: SelfMessageRecord): string {
41
+ const time = String(record.sentAt).padStart(16, '0')
42
+ return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
43
+ }
44
+
45
+ function contactKey(platform: string, selfId: string, type: string): string {
46
+ return `c:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(type)}`
47
+ }
48
+
49
+ function legacyContactKey(platform: string, selfId: string, type: string): string {
50
+ return `c:${platform}:${selfId}:${type}`
51
+ }
52
+
53
+ function groupMemberKey(platform: string, selfId: string, groupId: string): string {
54
+ return `gm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(groupId)}`
55
+ }
56
+
57
+ function legacyGroupMemberKey(platform: string, selfId: string, groupId: string): string {
58
+ return `gm:${platform}:${selfId}:${groupId}`
59
+ }
60
+
61
+ function isPrivateChannelType(value: unknown): boolean {
62
+ const num = Number(value)
63
+ if (Number.isFinite(num)) return num === 1
64
+ const text = String(value ?? '').toLowerCase()
65
+ return text === 'direct' || text === 'private'
66
+ }
67
+
68
+ function isUsableGroupContact(item: ContactCacheItem): boolean {
69
+ if (typeof item.raw === 'object' && item.raw !== null) {
70
+ const raw = item.raw as Record<string, unknown>
71
+ if (isPrivateChannelType(raw.channel_type)) return false
72
+ }
73
+ return true
74
+ }
75
+
76
+ type CompactableLevel = Level<string, string> & {
77
+ compactRange(start: string, end: string): Promise<void>
59
78
  }
60
79
 
61
- function isPrivateChannelType(value: unknown): boolean {
62
- const num = Number(value)
63
- if (Number.isFinite(num)) return num === 1
64
- const text = String(value ?? '').toLowerCase()
65
- return text === 'direct' || text === 'private'
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>
66
87
  }
67
88
 
68
- function isUsableGroupContact(item: ContactCacheItem): boolean {
69
- if (typeof item.raw === 'object' && item.raw !== null) {
70
- const raw = item.raw as Record<string, unknown>
71
- if (isPrivateChannelType(raw.channel_type)) return false
89
+ const sharedDatabases = new Map<string, SharedDatabase>()
90
+
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)
97
+ }
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)
72
106
  }
73
- return true
107
+ shared.refs += 1
108
+ return shared
74
109
  }
75
110
 
76
- type CompactableLevel = Level<string, string> & {
77
- compactRange(start: string, end: string): Promise<void>
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
117
+ }
118
+ if (!shared.opened) {
119
+ if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
120
+ return true
121
+ }
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
78
129
  }
79
130
 
80
131
  export class ChatDatabase {
81
- private db: Level<string, string>
82
- private opened = false
132
+ private readonly dir: string
133
+ private shared?: SharedDatabase
83
134
 
84
135
  constructor(
85
136
  private ctx: Context,
86
137
  private config: Config,
87
138
  private logger: PluginLogger,
88
139
  ) {
89
- const dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
90
- this.db = new Level<string, string>(dir, { keyEncoding: 'utf8', valueEncoding: 'utf8' })
91
- }
92
-
93
- async initialize() {
94
- await this.db.open()
95
- this.opened = true
96
- this.logger.logInfo('LevelDB 已打开:', this.db.location)
97
- }
98
-
99
- async dispose() {
100
- if (!this.opened) return
101
- await this.db.close()
102
- this.opened = false
103
- this.logger.logInfo('LevelDB closed')
104
- }
105
-
106
- async clearAll() {
107
- // 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
108
- await this.db.clear()
109
- const db = this.db as unknown as CompactableLevel
110
- await db.compactRange('', '\uffff')
111
- this.logger.logInfo('数据库缓存已全部清空并完成压缩')
112
- }
113
-
114
- async appendMessage(record: MessageRecord) {
115
- await this.db.put(messageKey(record), JSON.stringify(record))
116
- await this.trimMessages(record.platform, record.selfId, record.channelId || '')
117
- }
118
-
119
- async upsertSelfMessage(record: SelfMessageRecord) {
120
- if (record.messageId) {
121
- await this.removeSelfMessageByMessageId(
122
- record.platform,
123
- record.selfId,
124
- record.channelId,
125
- record.messageId,
126
- record.id,
127
- )
128
- }
129
- await this.db.put(selfMessageKey(record), JSON.stringify(record))
130
- await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
131
- }
132
-
133
- async listSelfMessages(
134
- platform: string,
135
- selfId: string,
136
- channelId: string,
137
- limit = this.config.historyPageSize,
138
- ): Promise<SelfMessageRecord[]> {
139
- const result: SelfMessageRecord[] = []
140
- const prefix = selfMessagePrefix(platform, selfId, channelId)
141
- for await (const [, value] of this.db.iterator<string, string>({
142
- gte: prefix,
143
- lte: `${prefix}\uffff`,
144
- reverse: true,
145
- limit,
146
- })) {
147
- const parsed = this.parseSelfMessage(value)
148
- if (parsed) result.push(parsed)
149
- }
150
- return result
151
- }
152
-
153
- async listSelfMessagesBefore(
154
- platform: string,
155
- selfId: string,
156
- channelId: string,
157
- beforeTime: number,
158
- limit = this.config.historyPageSize,
159
- ): Promise<SelfMessageRecord[]> {
160
- const result: SelfMessageRecord[] = []
161
- const prefix = selfMessagePrefix(platform, selfId, channelId)
162
- const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
163
- for await (const [, value] of this.db.iterator<string, string>({
164
- gte: prefix,
165
- lt: before,
166
- reverse: true,
167
- limit,
168
- })) {
169
- const parsed = this.parseSelfMessage(value)
170
- if (parsed) result.push(parsed)
171
- }
172
- return result
173
- }
174
-
175
- async updateSelfMessageByMessageId(
176
- platform: string,
177
- selfId: string,
178
- _channelId: string,
179
- messageId: string,
180
- patch: Partial<SelfMessageRecord>,
181
- ): Promise<boolean> {
182
- const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
183
- for await (const [key, value] of this.db.iterator<string, string>({
184
- gte: prefix,
185
- lte: `${prefix}\uffff`,
186
- })) {
187
- const parsed = this.parseSelfMessage(value)
188
- if (!parsed || parsed.messageId !== messageId) continue
189
- const next: SelfMessageRecord = {
190
- ...parsed,
191
- ...patch,
192
- id: parsed.id,
193
- sentAt: parsed.sentAt,
194
- }
195
- await this.db.put(key, JSON.stringify(next))
196
- return true
197
- }
198
- return false
199
- }
200
-
201
- async updateMessageRevoked(
202
- platform: string,
203
- selfId: string,
204
- channelId: string,
205
- messageId: string,
206
- patch: { revoked: boolean; revokedAt: number },
207
- ): Promise<boolean> {
208
- const operations: Array<{ type: 'put'; key: string; value: string }> = []
209
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
210
- for await (const [key, value] of this.db.iterator<string, string>({
211
- gte: prefix,
212
- lte: `${prefix}\uffff`,
213
- })) {
214
- try {
215
- const parsed = JSON.parse(value) as unknown
216
- if (typeof parsed !== 'object' || parsed === null) continue
217
- const record = parsed as Partial<MessageRecord>
218
- const raw = typeof record.raw === 'object' && record.raw !== null
219
- ? record.raw as Record<string, unknown>
220
- : {}
221
- const rawMessage = typeof raw.message === 'object' && raw.message !== null
222
- ? raw.message as Record<string, unknown>
223
- : {}
224
- const id = String(record.id ?? rawMessage.id ?? '')
225
- if (!id || id !== messageId) continue
226
- operations.push({
227
- type: 'put',
228
- key,
229
- value: JSON.stringify({ ...parsed, ...patch }),
230
- })
231
- } catch {
232
- // 单条解析失败不影响其他消息
233
- }
234
- }
235
- }
236
- if (operations.length) await this.db.batch(operations)
237
- return operations.length > 0
140
+ this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
238
141
  }
239
142
 
240
- async findSelfForwardContent(
241
- platform: string,
242
- selfId: string,
243
- channelId: string,
244
- id: string,
245
- ): Promise<unknown[] | null> {
246
- const prefixes = channelId
247
- ? [selfMessagePrefix(platform, selfId, channelId)]
248
- : [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
249
- for (const prefix of prefixes) {
250
- for await (const [, value] of this.db.iterator<string, string>({
251
- gte: prefix,
252
- lte: `${prefix}\uffff`,
253
- })) {
254
- const parsed = this.parseSelfMessage(value)
255
- if (!parsed) continue
256
- if (parsed.forwardId === id || parsed.messageId === id) {
257
- if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
258
- return parsed.forwardContent
259
- }
260
- }
261
- }
262
- }
263
- return null
264
- }
265
-
266
- private async removeSelfMessageByMessageId(
267
- platform: string,
268
- selfId: string,
269
- _channelId: string,
270
- messageId: string,
271
- exceptId: string,
272
- ) {
273
- const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
274
- const toDelete: string[] = []
275
- for await (const [key, value] of this.db.iterator<string, string>({
276
- gte: prefix,
277
- lte: `${prefix}\uffff`,
278
- })) {
279
- const parsed = this.parseSelfMessage(value)
280
- if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
281
- toDelete.push(key)
282
- }
283
- }
284
- if (toDelete.length) {
285
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
286
- }
143
+ private get db(): Level<string, string> {
144
+ if (!this.shared) throw new Error('LevelDB is not initialized')
145
+ return this.shared.db
287
146
  }
288
147
 
289
- private parseSelfMessage(value: string): SelfMessageRecord | null {
290
- try {
291
- const parsed = JSON.parse(value) as unknown
292
- if (typeof parsed === 'object' && parsed !== null) {
293
- const record = parsed as Partial<SelfMessageRecord>
294
- if (typeof record.id === 'string'
295
- && typeof record.platform === 'string'
296
- && typeof record.selfId === 'string'
297
- && typeof record.channelId === 'string'
298
- && typeof record.sentAt === 'number') {
299
- return record as SelfMessageRecord
300
- }
301
- }
302
- } catch {
303
- this.logger.warn('机器人消息解析失败:', value.slice(0, 120))
304
- }
305
- return null
306
- }
307
-
308
- async listMessages(
309
- platform: string,
310
- selfId: string,
311
- channelId: string,
312
- limit = this.config.historyPageSize,
313
- ): Promise<MessageRecord[]> {
314
- const result: MessageRecord[] = []
315
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
316
- for await (const [, value] of this.db.iterator<string, string>({
317
- gte: prefix,
318
- lte: `${prefix}\uffff`,
319
- reverse: true,
320
- limit: limit - result.length,
321
- })) {
322
- try {
323
- const parsed = JSON.parse(value) as unknown
324
- if (typeof parsed === 'object' && parsed !== null) {
325
- result.push(parsed as MessageRecord)
326
- }
327
- } catch {
328
- this.logger.warn('历史消息解析失败:', value.slice(0, 120))
329
- }
330
- }
331
- if (result.length >= limit) break
332
- }
333
- return result
334
- }
335
-
336
- async listMessagesBefore(
337
- platform: string,
338
- selfId: string,
339
- channelId: string,
340
- beforeTime: number,
341
- limit = this.config.historyPageSize,
342
- ): Promise<MessageRecord[]> {
343
- const result: MessageRecord[] = []
344
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
345
- const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
346
- for await (const [, value] of this.db.iterator<string, string>({
347
- gte: prefix,
348
- lt: before,
349
- reverse: true,
350
- limit: limit - result.length,
351
- })) {
352
- try {
353
- const parsed = JSON.parse(value) as unknown
354
- if (typeof parsed === 'object' && parsed !== null) {
355
- result.push(parsed as MessageRecord)
356
- }
357
- } catch {
358
- this.logger.warn('历史消息解析失败:', value.slice(0, 120))
359
- }
360
- }
361
- if (result.length >= limit) break
362
- }
363
- return result
364
- }
365
-
366
- async clearChannel(platform: string, selfId: string, channelId: string) {
367
- const operations: Array<{ type: 'del'; key: string }> = []
368
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
369
- for await (const [key] of this.db.iterator<string, string>({
370
- gte: prefix,
371
- lte: `${prefix}\uffff`,
372
- })) {
373
- operations.push({ type: 'del', key })
374
- }
375
- }
376
- const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
377
- for await (const [key] of this.db.iterator<string, string>({
378
- gte: selfPrefix,
379
- lte: `${selfPrefix}\uffff`,
380
- })) {
381
- operations.push({ type: 'del', key })
382
- }
383
- if (operations.length) await this.db.batch(operations)
384
- }
385
-
386
- async getPinned(): Promise<PinnedState> {
387
- try {
388
- const value = await this.db.get('meta:pinned')
389
- const parsed = JSON.parse(value) as unknown
390
- if (typeof parsed === 'object' && parsed !== null) {
391
- return parsed as PinnedState
392
- }
393
- } catch {
394
- // 首次使用没有置顶数据
395
- }
396
- return { bots: [], channels: [] }
397
- }
398
-
399
- async setPinned(state: PinnedState) {
400
- await this.db.put('meta:pinned', JSON.stringify(state))
401
- }
402
-
403
- async getMeta(key: string): Promise<unknown> {
404
- try {
405
- return JSON.parse(await this.db.get(`meta:${key}`)) as unknown
406
- } catch {
407
- return undefined
408
- }
409
- }
410
-
411
- async setMeta(key: string, value: unknown) {
412
- await this.db.put(`meta:${key}`, JSON.stringify(value))
413
- }
414
-
415
- async recordMedia(url: string, filePath: string, channelId = '') {
416
- const key = `media:${createHash('md5').update(url).digest('hex')}`
417
- await this.db.put(key, JSON.stringify({ filePath, channelId }))
418
- }
419
-
420
- async getMediaPath(url: string): Promise<string | undefined> {
148
+ async initialize() {
149
+ this.shared = await acquireSharedDatabase(this.dir)
421
150
  try {
422
- const key = `media:${createHash('md5').update(url).digest('hex')}`
423
- const value = await this.db.get(key)
424
- try {
425
- const parsed = JSON.parse(value) as unknown
426
- if (typeof parsed === 'object' && parsed !== null) {
427
- const item = parsed as { filePath?: unknown }
428
- return typeof item.filePath === 'string' ? item.filePath : value
429
- }
430
- } catch {
431
- // 旧记录直接存的是文件路径
432
- }
433
- return value
434
- } catch {
435
- return undefined
436
- }
437
- }
438
-
439
- async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
440
- const result: Array<{ filePath: string; channelId: string }> = []
441
- for await (const [, value] of this.db.iterator<string, string>({
442
- gte: 'media:',
443
- lte: 'media:\uffff',
444
- })) {
445
- try {
446
- const parsed = JSON.parse(value) as unknown
447
- if (typeof parsed === 'object' && parsed !== null) {
448
- const item = parsed as { filePath?: unknown; channelId?: unknown }
449
- if (typeof item.filePath === 'string') {
450
- result.push({
451
- filePath: item.filePath,
452
- channelId: typeof item.channelId === 'string' ? item.channelId : '',
453
- })
454
- }
455
- } else if (typeof parsed === 'string') {
456
- result.push({ filePath: parsed, channelId: '' })
457
- }
458
- } catch {
459
- result.push({ filePath: value, channelId: '' })
460
- }
151
+ await this.ensureOpen()
152
+ this.logger.logInfo('LevelDB 已打开:', this.shared.db.location)
153
+ } catch (error) {
154
+ await this.releaseShared()
155
+ throw error
461
156
  }
462
- return result
463
157
  }
464
158
 
465
- async removeMediaByPath(filePath: string) {
466
- const normalized = path.normalize(filePath)
467
- const toDelete: string[] = []
468
- for await (const [key, value] of this.db.iterator<string, string>({
469
- gte: 'media:',
470
- lte: 'media:\uffff',
471
- })) {
472
- let stored = value
473
- try {
474
- const parsed = JSON.parse(value) as unknown
475
- if (typeof parsed === 'object' && parsed !== null) {
476
- const item = parsed as { filePath?: unknown }
477
- stored = typeof item.filePath === 'string' ? item.filePath : value
478
- }
479
- } catch {
480
- // 旧格式直接存路径
481
- }
482
- if (path.normalize(stored) === normalized) toDelete.push(key)
483
- }
484
- if (toDelete.length) {
485
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
486
- }
159
+ async dispose() {
160
+ const closed = await this.releaseShared()
161
+ if (closed) this.logger.logInfo('LevelDB closed')
487
162
  }
488
163
 
489
- async getContacts(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
490
- for (const key of [contactKey(platform, selfId, type), legacyContactKey(platform, selfId, type)]) {
491
- try {
492
- const value = await this.db.get(key)
493
- const parsed = JSON.parse(value) as unknown
494
- if (Array.isArray(parsed)) {
495
- const contacts = parsed as ContactCacheItem[]
496
- return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
497
- }
498
- } catch {
499
- // 旧 key 或首次使用可能不存在
500
- }
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
501
170
  }
502
- return []
503
- }
504
-
505
- async getContact(
506
- platform: string,
507
- selfId: string,
508
- type: string,
509
- id: string,
510
- ): Promise<ContactCacheItem | null> {
511
- const contacts = await this.getContacts(platform, selfId, type)
512
- return contacts.find((item) => item.id === id) ?? null
513
- }
514
-
515
- async setContacts(platform: string, selfId: string, type: string, contacts: ContactCacheItem[]) {
516
- const next = type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
517
- await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next))
518
- }
519
-
520
- async appendContact(platform: string, selfId: string, type: string, contact: ContactCacheItem) {
521
- if (type === 'group' && !isUsableGroupContact(contact)) return
522
- const contacts = await this.getContacts(platform, selfId, type)
523
- const next = contacts.filter((item) => item.id !== contact.id)
524
- next.push(contact)
525
- await this.setContacts(platform, selfId, type, next)
526
- }
527
-
528
- async getContactsLegacy(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
171
+ const opening = shared.db.open().then(() => {
172
+ shared.opened = true
173
+ })
174
+ shared.opening = opening
529
175
  try {
530
- const value = await this.db.get(legacyContactKey(platform, selfId, type))
531
- const parsed = JSON.parse(value) as unknown
532
- if (!Array.isArray(parsed)) return []
533
- const contacts = parsed as ContactCacheItem[]
534
- return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
535
- } catch {
536
- return []
537
- }
538
- }
539
-
540
- async getGroupMembers(
541
- platform: string,
542
- selfId: string,
543
- groupId: string,
544
- ): Promise<ContactCacheItem[]> {
545
- for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
546
- try {
547
- const value = await this.db.get(key)
548
- const parsed = JSON.parse(value) as unknown
549
- return Array.isArray(parsed) ? parsed as ContactCacheItem[] : []
550
- } catch {
551
- // 新键或旧键可能不存在,继续尝试另一个
552
- }
176
+ await opening
177
+ } finally {
178
+ shared.opening = undefined
553
179
  }
554
- return []
555
180
  }
556
181
 
557
- async setGroupMembers(
558
- platform: string,
559
- selfId: string,
560
- groupId: string,
561
- members: ContactCacheItem[],
562
- ) {
563
- await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members))
564
- }
565
-
566
- async getGroupMember(
567
- platform: string,
568
- selfId: string,
569
- groupId: string,
570
- userId: string,
571
- ): Promise<ContactCacheItem | null> {
572
- const members = await this.getGroupMembers(platform, selfId, groupId)
573
- return members.find((item) => item.id === userId) ?? null
574
- }
575
-
576
- async appendGroupMember(
577
- platform: string,
578
- selfId: string,
579
- groupId: string,
580
- member: ContactCacheItem,
581
- ) {
582
- const members = await this.getGroupMembers(platform, selfId, groupId)
583
- const next = members.filter((item) => item.id !== member.id)
584
- next.push(member)
585
- await this.setGroupMembers(platform, selfId, groupId, next)
586
- }
587
-
588
- async getAllContacts(): Promise<Array<{
589
- platform: string
590
- selfId: string
591
- type: string
592
- contacts: ContactCacheItem[]
593
- }>> {
594
- type ContactEntry = {
595
- platform: string
596
- selfId: string
597
- type: string
598
- contacts: ContactCacheItem[]
599
- }
600
- const result: Array<{
601
- platform: string
602
- selfId: string
603
- type: string
604
- contacts: ContactCacheItem[]
605
- }> = []
606
- const byTriple = new Map<string, {
607
- kind: 'new' | 'legacy'
608
- entry: ContactEntry
609
- }>()
610
- for await (const [key, value] of this.db.iterator<string, string>({
611
- gte: 'c:',
612
- lte: 'c:\uffff',
613
- })) {
614
- if (!key.startsWith('c:')) continue
615
- let source: 'new' | 'legacy' = 'legacy'
616
- let platform = ''
617
- let selfId = ''
618
- let type = ''
619
- const parts = key.slice(2).split(':')
620
- if (parts.length === 3) {
621
- platform = decodeKeyPart(parts[0])
622
- selfId = decodeKeyPart(parts[1])
623
- type = decodeKeyPart(parts[2])
624
- source = 'new'
625
- } else {
626
- const typeIndex = key.lastIndexOf(':')
627
- if (typeIndex <= 2) continue
628
- type = key.slice(typeIndex + 1)
629
- const rest = key.slice(2, typeIndex)
630
- const sep = rest.lastIndexOf(':')
631
- if (sep <= 0) continue
632
- platform = rest.slice(0, sep)
633
- selfId = rest.slice(sep + 1)
634
- }
635
- if (!platform || !selfId || !type) continue
636
- try {
637
- const parsed = JSON.parse(value) as unknown
638
- const entry: ContactEntry = {
639
- platform,
640
- selfId,
641
- type,
642
- contacts: Array.isArray(parsed) ? parsed as ContactCacheItem[] : [],
643
- }
644
- if (entry.type === 'group') {
645
- entry.contacts = entry.contacts.filter(isUsableGroupContact)
646
- }
647
- const triple = JSON.stringify([platform, selfId, type])
648
- const existing = byTriple.get(triple)
649
- if (!existing || (existing.kind === 'legacy' && source === 'new')) {
650
- byTriple.set(triple, { kind: source, entry })
651
- }
652
- } catch {
653
- this.logger.warn('联系人缓存解析失败:', key)
654
- }
655
- }
656
- for (const { entry } of byTriple.values()) {
657
- result.push(entry)
658
- }
659
- return result
660
- }
661
-
662
- private async trimMessages(platform: string, selfId: string, channelId: string) {
663
- let count = 0
664
- const toDelete: string[] = []
665
- for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
666
- for await (const [key] of this.db.iterator<string, string>({
667
- gte: prefix,
668
- lte: `${prefix}\uffff`,
669
- })) {
670
- count += 1
671
- if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
672
- }
673
- }
674
- if (!toDelete.length) return
675
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
676
- this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
677
- }
678
-
679
- private async trimSelfMessages(platform: string, selfId: string, channelId: string) {
680
- let count = 0
681
- const toDelete: string[] = []
682
- const prefix = selfMessagePrefix(platform, selfId, channelId)
683
- for await (const [key] of this.db.iterator<string, string>({
684
- gte: prefix,
685
- lte: `${prefix}\uffff`,
686
- })) {
687
- count += 1
688
- if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
689
- }
690
- if (!toDelete.length) return
691
- await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
692
- this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
182
+ private async releaseShared(): Promise<boolean> {
183
+ const shared = this.shared
184
+ this.shared = undefined
185
+ if (shared) return releaseSharedDatabase(shared)
186
+ return false
693
187
  }
694
- }
188
+
189
+ async clearAll() {
190
+ // 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
191
+ await this.db.clear()
192
+ const db = this.db as unknown as CompactableLevel
193
+ await db.compactRange('', '\uffff')
194
+ this.logger.logInfo('数据库缓存已全部清空并完成压缩')
195
+ }
196
+
197
+ async appendMessage(record: MessageRecord) {
198
+ await this.db.put(messageKey(record), JSON.stringify(record))
199
+ await this.trimMessages(record.platform, record.selfId, record.channelId || '')
200
+ }
201
+
202
+ async upsertSelfMessage(record: SelfMessageRecord) {
203
+ if (record.messageId) {
204
+ await this.removeSelfMessageByMessageId(
205
+ record.platform,
206
+ record.selfId,
207
+ record.channelId,
208
+ record.messageId,
209
+ record.id,
210
+ )
211
+ }
212
+ await this.db.put(selfMessageKey(record), JSON.stringify(record))
213
+ await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
214
+ }
215
+
216
+ async listSelfMessages(
217
+ platform: string,
218
+ selfId: string,
219
+ channelId: string,
220
+ limit = this.config.historyPageSize,
221
+ ): Promise<SelfMessageRecord[]> {
222
+ const result: SelfMessageRecord[] = []
223
+ const prefix = selfMessagePrefix(platform, selfId, channelId)
224
+ for await (const [, value] of this.db.iterator<string, string>({
225
+ gte: prefix,
226
+ lte: `${prefix}\uffff`,
227
+ reverse: true,
228
+ limit,
229
+ })) {
230
+ const parsed = this.parseSelfMessage(value)
231
+ if (parsed) result.push(parsed)
232
+ }
233
+ return result
234
+ }
235
+
236
+ async listSelfMessagesBefore(
237
+ platform: string,
238
+ selfId: string,
239
+ channelId: string,
240
+ beforeTime: number,
241
+ limit = this.config.historyPageSize,
242
+ ): Promise<SelfMessageRecord[]> {
243
+ const result: SelfMessageRecord[] = []
244
+ const prefix = selfMessagePrefix(platform, selfId, channelId)
245
+ const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
246
+ for await (const [, value] of this.db.iterator<string, string>({
247
+ gte: prefix,
248
+ lt: before,
249
+ reverse: true,
250
+ limit,
251
+ })) {
252
+ const parsed = this.parseSelfMessage(value)
253
+ if (parsed) result.push(parsed)
254
+ }
255
+ return result
256
+ }
257
+
258
+ async updateSelfMessageByMessageId(
259
+ platform: string,
260
+ selfId: string,
261
+ _channelId: string,
262
+ messageId: string,
263
+ patch: Partial<SelfMessageRecord>,
264
+ ): Promise<boolean> {
265
+ const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
266
+ for await (const [key, value] of this.db.iterator<string, string>({
267
+ gte: prefix,
268
+ lte: `${prefix}\uffff`,
269
+ })) {
270
+ const parsed = this.parseSelfMessage(value)
271
+ if (!parsed || parsed.messageId !== messageId) continue
272
+ const next: SelfMessageRecord = {
273
+ ...parsed,
274
+ ...patch,
275
+ id: parsed.id,
276
+ sentAt: parsed.sentAt,
277
+ }
278
+ await this.db.put(key, JSON.stringify(next))
279
+ return true
280
+ }
281
+ return false
282
+ }
283
+
284
+ async updateMessageRevoked(
285
+ platform: string,
286
+ selfId: string,
287
+ channelId: string,
288
+ messageId: string,
289
+ patch: { revoked: boolean; revokedAt: number },
290
+ ): Promise<boolean> {
291
+ const operations: Array<{ type: 'put'; key: string; value: string }> = []
292
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
293
+ for await (const [key, value] of this.db.iterator<string, string>({
294
+ gte: prefix,
295
+ lte: `${prefix}\uffff`,
296
+ })) {
297
+ try {
298
+ const parsed = JSON.parse(value) as unknown
299
+ if (typeof parsed !== 'object' || parsed === null) continue
300
+ const record = parsed as Partial<MessageRecord>
301
+ const raw = typeof record.raw === 'object' && record.raw !== null
302
+ ? record.raw as Record<string, unknown>
303
+ : {}
304
+ const rawMessage = typeof raw.message === 'object' && raw.message !== null
305
+ ? raw.message as Record<string, unknown>
306
+ : {}
307
+ const id = String(record.id ?? rawMessage.id ?? '')
308
+ if (!id || id !== messageId) continue
309
+ operations.push({
310
+ type: 'put',
311
+ key,
312
+ value: JSON.stringify({ ...parsed, ...patch }),
313
+ })
314
+ } catch {
315
+ // 单条解析失败不影响其他消息
316
+ }
317
+ }
318
+ }
319
+ if (operations.length) await this.db.batch(operations)
320
+ return operations.length > 0
321
+ }
322
+
323
+ async findSelfForwardContent(
324
+ platform: string,
325
+ selfId: string,
326
+ channelId: string,
327
+ id: string,
328
+ ): Promise<unknown[] | null> {
329
+ const prefixes = channelId
330
+ ? [selfMessagePrefix(platform, selfId, channelId)]
331
+ : [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
332
+ for (const prefix of prefixes) {
333
+ for await (const [, value] of this.db.iterator<string, string>({
334
+ gte: prefix,
335
+ lte: `${prefix}\uffff`,
336
+ })) {
337
+ const parsed = this.parseSelfMessage(value)
338
+ if (!parsed) continue
339
+ if (parsed.forwardId === id || parsed.messageId === id) {
340
+ if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
341
+ return parsed.forwardContent
342
+ }
343
+ }
344
+ }
345
+ }
346
+ return null
347
+ }
348
+
349
+ private async removeSelfMessageByMessageId(
350
+ platform: string,
351
+ selfId: string,
352
+ _channelId: string,
353
+ messageId: string,
354
+ exceptId: string,
355
+ ) {
356
+ const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
357
+ const toDelete: string[] = []
358
+ for await (const [key, value] of this.db.iterator<string, string>({
359
+ gte: prefix,
360
+ lte: `${prefix}\uffff`,
361
+ })) {
362
+ const parsed = this.parseSelfMessage(value)
363
+ if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
364
+ toDelete.push(key)
365
+ }
366
+ }
367
+ if (toDelete.length) {
368
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
369
+ }
370
+ }
371
+
372
+ private parseSelfMessage(value: string): SelfMessageRecord | null {
373
+ try {
374
+ const parsed = JSON.parse(value) as unknown
375
+ if (typeof parsed === 'object' && parsed !== null) {
376
+ const record = parsed as Partial<SelfMessageRecord>
377
+ if (typeof record.id === 'string'
378
+ && typeof record.platform === 'string'
379
+ && typeof record.selfId === 'string'
380
+ && typeof record.channelId === 'string'
381
+ && typeof record.sentAt === 'number') {
382
+ return record as SelfMessageRecord
383
+ }
384
+ }
385
+ } catch {
386
+ this.logger.warn('机器人消息解析失败:', value.slice(0, 120))
387
+ }
388
+ return null
389
+ }
390
+
391
+ async listMessages(
392
+ platform: string,
393
+ selfId: string,
394
+ channelId: string,
395
+ limit = this.config.historyPageSize,
396
+ ): Promise<MessageRecord[]> {
397
+ const result: MessageRecord[] = []
398
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
399
+ for await (const [, value] of this.db.iterator<string, string>({
400
+ gte: prefix,
401
+ lte: `${prefix}\uffff`,
402
+ reverse: true,
403
+ limit: limit - result.length,
404
+ })) {
405
+ try {
406
+ const parsed = JSON.parse(value) as unknown
407
+ if (typeof parsed === 'object' && parsed !== null) {
408
+ result.push(parsed as MessageRecord)
409
+ }
410
+ } catch {
411
+ this.logger.warn('历史消息解析失败:', value.slice(0, 120))
412
+ }
413
+ }
414
+ if (result.length >= limit) break
415
+ }
416
+ return result
417
+ }
418
+
419
+ async listMessagesBefore(
420
+ platform: string,
421
+ selfId: string,
422
+ channelId: string,
423
+ beforeTime: number,
424
+ limit = this.config.historyPageSize,
425
+ ): Promise<MessageRecord[]> {
426
+ const result: MessageRecord[] = []
427
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
428
+ const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
429
+ for await (const [, value] of this.db.iterator<string, string>({
430
+ gte: prefix,
431
+ lt: before,
432
+ reverse: true,
433
+ limit: limit - result.length,
434
+ })) {
435
+ try {
436
+ const parsed = JSON.parse(value) as unknown
437
+ if (typeof parsed === 'object' && parsed !== null) {
438
+ result.push(parsed as MessageRecord)
439
+ }
440
+ } catch {
441
+ this.logger.warn('历史消息解析失败:', value.slice(0, 120))
442
+ }
443
+ }
444
+ if (result.length >= limit) break
445
+ }
446
+ return result
447
+ }
448
+
449
+ async clearChannel(platform: string, selfId: string, channelId: string) {
450
+ const operations: Array<{ type: 'del'; key: string }> = []
451
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
452
+ for await (const [key] of this.db.iterator<string, string>({
453
+ gte: prefix,
454
+ lte: `${prefix}\uffff`,
455
+ })) {
456
+ operations.push({ type: 'del', key })
457
+ }
458
+ }
459
+ const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
460
+ for await (const [key] of this.db.iterator<string, string>({
461
+ gte: selfPrefix,
462
+ lte: `${selfPrefix}\uffff`,
463
+ })) {
464
+ operations.push({ type: 'del', key })
465
+ }
466
+ if (operations.length) await this.db.batch(operations)
467
+ }
468
+
469
+ async getPinned(): Promise<PinnedState> {
470
+ try {
471
+ const value = await this.db.get('meta:pinned')
472
+ const parsed = JSON.parse(value) as unknown
473
+ if (typeof parsed === 'object' && parsed !== null) {
474
+ return parsed as PinnedState
475
+ }
476
+ } catch {
477
+ // 首次使用没有置顶数据
478
+ }
479
+ return { bots: [], channels: [] }
480
+ }
481
+
482
+ async setPinned(state: PinnedState) {
483
+ await this.db.put('meta:pinned', JSON.stringify(state))
484
+ }
485
+
486
+ async getMeta(key: string): Promise<unknown> {
487
+ try {
488
+ return JSON.parse(await this.db.get(`meta:${key}`)) as unknown
489
+ } catch {
490
+ return undefined
491
+ }
492
+ }
493
+
494
+ async setMeta(key: string, value: unknown) {
495
+ await this.db.put(`meta:${key}`, JSON.stringify(value))
496
+ }
497
+
498
+ async recordMedia(url: string, filePath: string, channelId = '') {
499
+ const key = `media:${createHash('md5').update(url).digest('hex')}`
500
+ await this.db.put(key, JSON.stringify({ filePath, channelId }))
501
+ }
502
+
503
+ async getMediaPath(url: string): Promise<string | undefined> {
504
+ try {
505
+ const key = `media:${createHash('md5').update(url).digest('hex')}`
506
+ const value = await this.db.get(key)
507
+ try {
508
+ const parsed = JSON.parse(value) as unknown
509
+ if (typeof parsed === 'object' && parsed !== null) {
510
+ const item = parsed as { filePath?: unknown }
511
+ return typeof item.filePath === 'string' ? item.filePath : value
512
+ }
513
+ } catch {
514
+ // 旧记录直接存的是文件路径
515
+ }
516
+ return value
517
+ } catch {
518
+ return undefined
519
+ }
520
+ }
521
+
522
+ async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
523
+ const result: Array<{ filePath: string; channelId: string }> = []
524
+ for await (const [, value] of this.db.iterator<string, string>({
525
+ gte: 'media:',
526
+ lte: 'media:\uffff',
527
+ })) {
528
+ try {
529
+ const parsed = JSON.parse(value) as unknown
530
+ if (typeof parsed === 'object' && parsed !== null) {
531
+ const item = parsed as { filePath?: unknown; channelId?: unknown }
532
+ if (typeof item.filePath === 'string') {
533
+ result.push({
534
+ filePath: item.filePath,
535
+ channelId: typeof item.channelId === 'string' ? item.channelId : '',
536
+ })
537
+ }
538
+ } else if (typeof parsed === 'string') {
539
+ result.push({ filePath: parsed, channelId: '' })
540
+ }
541
+ } catch {
542
+ result.push({ filePath: value, channelId: '' })
543
+ }
544
+ }
545
+ return result
546
+ }
547
+
548
+ async removeMediaByPath(filePath: string) {
549
+ const normalized = path.normalize(filePath)
550
+ const toDelete: string[] = []
551
+ for await (const [key, value] of this.db.iterator<string, string>({
552
+ gte: 'media:',
553
+ lte: 'media:\uffff',
554
+ })) {
555
+ let stored = value
556
+ try {
557
+ const parsed = JSON.parse(value) as unknown
558
+ if (typeof parsed === 'object' && parsed !== null) {
559
+ const item = parsed as { filePath?: unknown }
560
+ stored = typeof item.filePath === 'string' ? item.filePath : value
561
+ }
562
+ } catch {
563
+ // 旧格式直接存路径
564
+ }
565
+ if (path.normalize(stored) === normalized) toDelete.push(key)
566
+ }
567
+ if (toDelete.length) {
568
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
569
+ }
570
+ }
571
+
572
+ async getContacts(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
573
+ for (const key of [contactKey(platform, selfId, type), legacyContactKey(platform, selfId, type)]) {
574
+ try {
575
+ const value = await this.db.get(key)
576
+ const parsed = JSON.parse(value) as unknown
577
+ if (Array.isArray(parsed)) {
578
+ const contacts = parsed as ContactCacheItem[]
579
+ return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
580
+ }
581
+ } catch {
582
+ // 旧 key 或首次使用可能不存在
583
+ }
584
+ }
585
+ return []
586
+ }
587
+
588
+ async getContact(
589
+ platform: string,
590
+ selfId: string,
591
+ type: string,
592
+ id: string,
593
+ ): Promise<ContactCacheItem | null> {
594
+ const contacts = await this.getContacts(platform, selfId, type)
595
+ return contacts.find((item) => item.id === id) ?? null
596
+ }
597
+
598
+ async setContacts(platform: string, selfId: string, type: string, contacts: ContactCacheItem[]) {
599
+ const next = type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
600
+ await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next))
601
+ }
602
+
603
+ async appendContact(platform: string, selfId: string, type: string, contact: ContactCacheItem) {
604
+ if (type === 'group' && !isUsableGroupContact(contact)) return
605
+ const contacts = await this.getContacts(platform, selfId, type)
606
+ const next = contacts.filter((item) => item.id !== contact.id)
607
+ next.push(contact)
608
+ await this.setContacts(platform, selfId, type, next)
609
+ }
610
+
611
+ async getContactsLegacy(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
612
+ try {
613
+ const value = await this.db.get(legacyContactKey(platform, selfId, type))
614
+ const parsed = JSON.parse(value) as unknown
615
+ if (!Array.isArray(parsed)) return []
616
+ const contacts = parsed as ContactCacheItem[]
617
+ return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
618
+ } catch {
619
+ return []
620
+ }
621
+ }
622
+
623
+ async getGroupMembers(
624
+ platform: string,
625
+ selfId: string,
626
+ groupId: string,
627
+ ): Promise<ContactCacheItem[]> {
628
+ for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
629
+ try {
630
+ const value = await this.db.get(key)
631
+ const parsed = JSON.parse(value) as unknown
632
+ return Array.isArray(parsed) ? parsed as ContactCacheItem[] : []
633
+ } catch {
634
+ // 新键或旧键可能不存在,继续尝试另一个
635
+ }
636
+ }
637
+ return []
638
+ }
639
+
640
+ async setGroupMembers(
641
+ platform: string,
642
+ selfId: string,
643
+ groupId: string,
644
+ members: ContactCacheItem[],
645
+ ) {
646
+ await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members))
647
+ }
648
+
649
+ async getGroupMember(
650
+ platform: string,
651
+ selfId: string,
652
+ groupId: string,
653
+ userId: string,
654
+ ): Promise<ContactCacheItem | null> {
655
+ const members = await this.getGroupMembers(platform, selfId, groupId)
656
+ return members.find((item) => item.id === userId) ?? null
657
+ }
658
+
659
+ async appendGroupMember(
660
+ platform: string,
661
+ selfId: string,
662
+ groupId: string,
663
+ member: ContactCacheItem,
664
+ ) {
665
+ const members = await this.getGroupMembers(platform, selfId, groupId)
666
+ const next = members.filter((item) => item.id !== member.id)
667
+ next.push(member)
668
+ await this.setGroupMembers(platform, selfId, groupId, next)
669
+ }
670
+
671
+ async getAllContacts(): Promise<Array<{
672
+ platform: string
673
+ selfId: string
674
+ type: string
675
+ contacts: ContactCacheItem[]
676
+ }>> {
677
+ type ContactEntry = {
678
+ platform: string
679
+ selfId: string
680
+ type: string
681
+ contacts: ContactCacheItem[]
682
+ }
683
+ const result: Array<{
684
+ platform: string
685
+ selfId: string
686
+ type: string
687
+ contacts: ContactCacheItem[]
688
+ }> = []
689
+ const byTriple = new Map<string, {
690
+ kind: 'new' | 'legacy'
691
+ entry: ContactEntry
692
+ }>()
693
+ for await (const [key, value] of this.db.iterator<string, string>({
694
+ gte: 'c:',
695
+ lte: 'c:\uffff',
696
+ })) {
697
+ if (!key.startsWith('c:')) continue
698
+ let source: 'new' | 'legacy' = 'legacy'
699
+ let platform = ''
700
+ let selfId = ''
701
+ let type = ''
702
+ const parts = key.slice(2).split(':')
703
+ if (parts.length === 3) {
704
+ platform = decodeKeyPart(parts[0])
705
+ selfId = decodeKeyPart(parts[1])
706
+ type = decodeKeyPart(parts[2])
707
+ source = 'new'
708
+ } else {
709
+ const typeIndex = key.lastIndexOf(':')
710
+ if (typeIndex <= 2) continue
711
+ type = key.slice(typeIndex + 1)
712
+ const rest = key.slice(2, typeIndex)
713
+ const sep = rest.lastIndexOf(':')
714
+ if (sep <= 0) continue
715
+ platform = rest.slice(0, sep)
716
+ selfId = rest.slice(sep + 1)
717
+ }
718
+ if (!platform || !selfId || !type) continue
719
+ try {
720
+ const parsed = JSON.parse(value) as unknown
721
+ const entry: ContactEntry = {
722
+ platform,
723
+ selfId,
724
+ type,
725
+ contacts: Array.isArray(parsed) ? parsed as ContactCacheItem[] : [],
726
+ }
727
+ if (entry.type === 'group') {
728
+ entry.contacts = entry.contacts.filter(isUsableGroupContact)
729
+ }
730
+ const triple = JSON.stringify([platform, selfId, type])
731
+ const existing = byTriple.get(triple)
732
+ if (!existing || (existing.kind === 'legacy' && source === 'new')) {
733
+ byTriple.set(triple, { kind: source, entry })
734
+ }
735
+ } catch {
736
+ this.logger.warn('联系人缓存解析失败:', key)
737
+ }
738
+ }
739
+ for (const { entry } of byTriple.values()) {
740
+ result.push(entry)
741
+ }
742
+ return result
743
+ }
744
+
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>({
750
+ gte: prefix,
751
+ lte: `${prefix}\uffff`,
752
+ })) {
753
+ count += 1
754
+ if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
755
+ }
756
+ }
757
+ if (!toDelete.length) return
758
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
759
+ this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
760
+ }
761
+
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)
772
+ }
773
+ if (!toDelete.length) return
774
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
775
+ this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
776
+ }
777
+ }