koishi-plugin-chat-patch 2.4.4 → 3.0.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.
@@ -1,112 +1,272 @@
1
- import { ChatData, MessageInfo } from './types'
2
- import { Context, Logger } from 'koishi'
1
+ import { BotInfo, ChannelInfo, ChatData, MessageInfo } from './types'
2
+ import { Context } from 'koishi'
3
3
  import { Config } from './config'
4
4
  import { Utils } from './utils'
5
+ import { PluginLogger } from './logger'
5
6
 
6
7
  import path from 'node:path'
7
- import fs from 'node:fs'
8
+ import { promises as fs } from 'node:fs'
9
+
10
+ interface ChannelChunkMeta {
11
+ id: number
12
+ fileName: string
13
+ messageCount: number
14
+ }
15
+
16
+ interface ChannelIndexData {
17
+ version: 1
18
+ channelId: string
19
+ totalMessages: number
20
+ nextChunkId: number
21
+ chunks: ChannelChunkMeta[]
22
+ }
23
+
24
+ interface StoredChannelEntry {
25
+ selfId: string
26
+ channelId: string
27
+ channelKey: string
28
+ channelDirPath: string
29
+ indexFilePath: string
30
+ }
8
31
 
9
32
  export class FileManager {
33
+ private storageBaseDir: string
10
34
  private chatHistoryDir: string // 聊天记录根目录
11
35
  private metadataFilePath: string // 元数据文件路径(存储bots、channels、pinned等信息)
12
- private logger: Logger
13
36
  private utils: Utils
14
37
 
15
- private memoryCache: ChatData | null = null
38
+ private memoryCache: ChatData = this.createEmptyChatData()
39
+
40
+ private channelMessagesCache: Map<string, MessageInfo[]> = new Map()
41
+
42
+ private recentMessageIdsCache: Map<string, string[]> = new Map()
43
+
44
+ private pendingBotMessageIds: Map<string, string[]> = new Map()
45
+
46
+ private messageChunkLocationCache: Map<string, Map<string, string>> = new Map()
47
+
48
+ private dirtyChannelKeys: Set<string> = new Set()
16
49
 
17
50
  private pendingMessages: Map<string, MessageInfo[]> = new Map() // 按channelKey分组的待写入消息
18
51
 
19
52
  private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
20
53
 
54
+ private metadataLoadPromise: Promise<void> | null = null
55
+
56
+ private channelLoadPromises: Map<string, Promise<MessageInfo[]>> = new Map()
57
+
58
+ private writeQueue: Promise<void> = Promise.resolve()
59
+
60
+ private disposed = false
61
+
21
62
  private readonly WRITE_DEBOUNCE_MS = 1000
22
63
 
64
+ private readonly RECENT_MESSAGE_ID_CACHE_SIZE = 200
65
+
23
66
  constructor(
24
67
  private ctx: Context,
25
- private config: Config
68
+ private config: Config,
69
+ private logger: PluginLogger
26
70
  ) {
27
71
  const baseDir = path.resolve(ctx.baseDir, 'data', 'chat-patch')
28
- this.chatHistoryDir = path.join(baseDir, 'chat-history')
29
- this.metadataFilePath = path.join(baseDir, 'metadata.json')
30
- this.logger = ctx.logger('chat-patch')
31
- this.utils = new Utils(config)
72
+ this.storageBaseDir = path.join(baseDir, 'v2')
73
+ this.chatHistoryDir = path.join(this.storageBaseDir, 'chat-history')
74
+ this.metadataFilePath = path.join(this.storageBaseDir, 'metadata.json')
75
+ this.utils = new Utils(config, ctx)
76
+
77
+ // 异步清理旧版本数据,避免阻塞启动。
78
+ this.ctx.setTimeout(() => {
79
+ void this.cleanupLegacyStorage(baseDir)
80
+ }, 0)
81
+ }
82
+
83
+ async initialize() {
84
+ await this.ensureMetadataLoaded()
85
+ }
86
+
87
+ readChatDataFromFile(): ChatData {
88
+ this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
89
+ return this.memoryCache
90
+ }
91
+
92
+ getCachedChannelInfo(selfId: string, channelId: string): ChannelInfo | undefined {
93
+ return this.memoryCache.channels[selfId]?.[channelId]
94
+ }
95
+
96
+ async readMetadataOnly(): Promise<Omit<ChatData, 'messages'>> {
97
+ await this.ensureMetadataLoaded()
98
+ const { messages, ...metadata } = this.memoryCache
99
+ return { ...metadata }
100
+ }
101
+
102
+ async upsertBotInfo(botInfo: BotInfo) {
103
+ await this.ensureMetadataLoaded()
104
+ const current = this.memoryCache.bots[botInfo.selfId]
105
+ if (current && this.isSameBotInfo(current, botInfo)) {
106
+ return
107
+ }
108
+
109
+ this.memoryCache.bots[botInfo.selfId] = botInfo
110
+ this.scheduleMetadataWrite()
111
+ }
32
112
 
33
- // 清理旧版本的单文件存储
34
- this.cleanupOldDataFiles(baseDir)
113
+ async upsertChannelInfo(selfId: string, channelId: string, channelInfo: ChannelInfo) {
114
+ await this.ensureMetadataLoaded()
35
115
 
36
- this.memoryCache = this.readChatDataFromFile()
116
+ if (!this.memoryCache.channels[selfId]) {
117
+ this.memoryCache.channels[selfId] = {}
118
+ }
119
+
120
+ const current = this.memoryCache.channels[selfId][channelId]
121
+ if (current && this.isSameChannelInfo(current, channelInfo)) {
122
+ return
123
+ }
124
+
125
+ this.memoryCache.channels[selfId][channelId] = channelInfo
126
+ this.scheduleMetadataWrite()
127
+ }
128
+
129
+ async setPinnedBots(pinnedBots: string[]) {
130
+ await this.ensureMetadataLoaded()
131
+ this.memoryCache.pinnedBots = [...pinnedBots]
132
+ this.scheduleMetadataWrite()
133
+ }
134
+
135
+ async setPinnedChannels(pinnedChannels: string[]) {
136
+ await this.ensureMetadataLoaded()
137
+ this.memoryCache.pinnedChannels = [...pinnedChannels]
138
+ this.scheduleMetadataWrite()
37
139
  }
38
140
 
39
- // 清理旧版本的JSON文件
40
- private cleanupOldDataFiles(baseDir: string) {
141
+ // 清理旧版本数据目录
142
+ private async cleanupLegacyStorage(baseDir: string) {
41
143
  const oldFiles = ['chat-data.json', 'data.json', 'messages.json']
42
144
  for (const fileName of oldFiles) {
43
145
  const filePath = path.join(baseDir, fileName)
44
- if (fs.existsSync(filePath)) {
45
- try {
46
- fs.unlinkSync(filePath)
47
- this.logger.info(`已删除旧版本数据文件: ${fileName}`)
48
- } catch (error) {
146
+ try {
147
+ await fs.unlink(filePath)
148
+ this.logger.logInfo(`已删除旧版本数据文件: ${fileName}`)
149
+ } catch (error) {
150
+ if (!this.isFileMissingError(error)) {
49
151
  this.logger.warn(`删除旧版本数据文件失败: ${fileName}`, error)
50
152
  }
51
153
  }
52
154
  }
53
- }
54
155
 
55
- // 确保目录存在
56
- private ensureDir(dirPath: string) {
57
- if (!fs.existsSync(dirPath)) {
58
- fs.mkdirSync(dirPath, { recursive: true })
156
+ const legacyPaths = [
157
+ path.join(baseDir, 'metadata.json'),
158
+ path.join(baseDir, 'chat-history')
159
+ ]
160
+
161
+ for (const legacyPath of legacyPaths) {
162
+ try {
163
+ await fs.rm(legacyPath, { recursive: true, force: true })
164
+ this.logger.logInfo(`已清理旧版数据路径: ${legacyPath}`)
165
+ } catch (error) {
166
+ this.logger.warn(`清理旧版数据路径失败: ${legacyPath}`, error)
167
+ }
59
168
  }
60
169
  }
61
170
 
62
- // 获取频道消息文件路径
63
- private getChannelFilePath(selfId: string, channelId: string): string {
64
- const botDir = path.join(this.chatHistoryDir, selfId)
65
- this.ensureDir(botDir)
66
- return path.join(botDir, `${channelId}.json`)
171
+ // 确保目录存在
172
+ private async ensureDir(dirPath: string) {
173
+ await fs.mkdir(dirPath, { recursive: true })
67
174
  }
68
175
 
69
176
  // 读取单个频道的消息(公共方法)
70
- readChannelMessages(selfId: string, channelId: string): MessageInfo[] {
71
- const filePath = this.getChannelFilePath(selfId, channelId)
72
- if (!fs.existsSync(filePath)) {
73
- return []
177
+ async readChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
178
+ await this.ensureMetadataLoaded()
179
+
180
+ const channelKey = `${selfId}:${channelId}`
181
+ const cached = this.getCachedChannelMessages(channelKey)
182
+ if (cached) {
183
+ return cached
74
184
  }
75
185
 
76
- try {
77
- const jsonData = fs.readFileSync(filePath, 'utf8')
78
- const messages = JSON.parse(jsonData)
79
- return Array.isArray(messages) ? messages : []
80
- } catch (error) {
81
- this.logger.error(`读取频道消息失败 [${selfId}:${channelId}]:`, error)
82
- return []
186
+ const existingPromise = this.channelLoadPromises.get(channelKey)
187
+ if (existingPromise) {
188
+ return existingPromise
83
189
  }
84
- }
85
190
 
86
- // 写入单个频道的消息
87
- private writeChannelMessages(selfId: string, channelId: string, messages: MessageInfo[]) {
88
- const filePath = this.getChannelFilePath(selfId, channelId)
191
+ const loadPromise = this.loadChannelMessages(selfId, channelId)
192
+ this.channelLoadPromises.set(channelKey, loadPromise)
193
+
89
194
  try {
90
- const jsonData = JSON.stringify(messages, null, 2)
91
- fs.writeFileSync(filePath, jsonData, 'utf8')
92
- } catch (error) {
93
- this.logger.error(`写入频道消息失败 [${selfId}:${channelId}]:`, error)
195
+ return await loadPromise
196
+ } finally {
197
+ this.channelLoadPromises.delete(channelKey)
94
198
  }
95
199
  }
96
200
 
97
- // 读取元数据(bots、channels、pinned等)
98
- private readMetadata(): Omit<ChatData, 'messages'> {
99
- if (!fs.existsSync(this.metadataFilePath)) {
201
+ async readChannelMessagesPage(selfId: string, channelId: string, limit: number, offset = 0): Promise<{ messages: MessageInfo[], total: number }> {
202
+ await this.ensureMetadataLoaded()
203
+
204
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
205
+ if (limit <= 0 || offset >= indexData.totalMessages) {
100
206
  return {
101
- bots: {},
102
- channels: {},
103
- pinnedBots: [],
104
- pinnedChannels: []
207
+ messages: [],
208
+ total: indexData.totalMessages
209
+ }
210
+ }
211
+
212
+ const entry = this.createStoredChannelEntry(selfId, channelId)
213
+ const collected: MessageInfo[] = []
214
+ let skipped = 0
215
+
216
+ for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
217
+ const chunk = indexData.chunks[chunkIndex]
218
+ const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
219
+
220
+ for (let messageIndex = chunkMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
221
+ if (skipped < offset) {
222
+ skipped += 1
223
+ continue
224
+ }
225
+
226
+ collected.push(chunkMessages[messageIndex])
227
+ if (collected.length >= limit) {
228
+ return {
229
+ messages: collected.reverse(),
230
+ total: indexData.totalMessages
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ return {
237
+ messages: collected.reverse(),
238
+ total: indexData.totalMessages
239
+ }
240
+ }
241
+
242
+ async findChannelMessageById(selfId: string, channelId: string, messageId: string): Promise<MessageInfo | null> {
243
+ await this.ensureMetadataLoaded()
244
+
245
+ const channelKey = `${selfId}:${channelId}`
246
+ const cachedMessages = this.peekCachedChannelMessages(channelKey)
247
+ const cachedMatched = cachedMessages?.find((message) => message.id === messageId)
248
+ if (cachedMatched) {
249
+ return cachedMatched
250
+ }
251
+
252
+ const entry = this.createStoredChannelEntry(selfId, channelId)
253
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
254
+ for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
255
+ const chunk = indexData.chunks[chunkIndex]
256
+ const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
257
+ const matched = messages.find((message) => message.id === messageId)
258
+ if (matched) {
259
+ return matched
105
260
  }
106
261
  }
107
262
 
263
+ return null
264
+ }
265
+
266
+ // 读取元数据(bots、channels、pinned等)
267
+ private async readMetadata(): Promise<Omit<ChatData, 'messages'>> {
108
268
  try {
109
- const jsonData = fs.readFileSync(this.metadataFilePath, 'utf8')
269
+ const jsonData = await fs.readFile(this.metadataFilePath, 'utf8')
110
270
  const data = JSON.parse(jsonData)
111
271
  return {
112
272
  bots: data.bots || {},
@@ -116,6 +276,15 @@ export class FileManager {
116
276
  lastSaveTime: data.lastSaveTime
117
277
  }
118
278
  } catch (error) {
279
+ if (this.isFileMissingError(error)) {
280
+ return {
281
+ bots: {},
282
+ channels: {},
283
+ pinnedBots: [],
284
+ pinnedChannels: []
285
+ }
286
+ }
287
+
119
288
  this.logger.error('读取元数据失败:', error)
120
289
  return {
121
290
  bots: {},
@@ -126,111 +295,21 @@ export class FileManager {
126
295
  }
127
296
  }
128
297
 
129
- // 只读取元数据,不加载消息(公共方法)
130
- readMetadataOnly(): Omit<ChatData, 'messages'> {
131
- return this.readMetadata()
132
- }
133
-
134
298
  // 写入元数据
135
- private writeMetadata(metadata: Omit<ChatData, 'messages'>) {
299
+ private async writeMetadata(metadata: Omit<ChatData, 'messages'>) {
136
300
  try {
137
- this.ensureDir(path.dirname(this.metadataFilePath))
301
+ await this.ensureDir(path.dirname(this.metadataFilePath))
138
302
  const dataToWrite = {
139
303
  ...metadata,
140
304
  lastSaveTime: Date.now()
141
305
  }
142
306
  const jsonData = JSON.stringify(dataToWrite, null, 2)
143
- fs.writeFileSync(this.metadataFilePath, jsonData, 'utf8')
307
+ await fs.writeFile(this.metadataFilePath, jsonData, 'utf8')
144
308
  } catch (error) {
145
309
  this.logger.error('写入元数据失败:', error)
146
310
  }
147
311
  }
148
312
 
149
- // 扫描所有频道消息文件并加载到内存
150
- private loadAllChannelMessages(): Record<string, MessageInfo[]> {
151
- const messages: Record<string, MessageInfo[]> = {}
152
-
153
- if (!fs.existsSync(this.chatHistoryDir)) {
154
- return messages
155
- }
156
-
157
- try {
158
- const botDirs = fs.readdirSync(this.chatHistoryDir)
159
- for (const botId of botDirs) {
160
- const botDir = path.join(this.chatHistoryDir, botId)
161
- const stat = fs.statSync(botDir)
162
- if (!stat.isDirectory()) continue
163
-
164
- const channelFiles = fs.readdirSync(botDir)
165
- for (const fileName of channelFiles) {
166
- if (!fileName.endsWith('.json')) continue
167
-
168
- const channelId = fileName.replace('.json', '')
169
- const channelKey = `${botId}:${channelId}`
170
- messages[channelKey] = this.readChannelMessages(botId, channelId)
171
- }
172
- }
173
- } catch (error) {
174
- this.logger.error('加载频道消息失败:', error)
175
- }
176
-
177
- return messages
178
- }
179
-
180
- readChatDataFromFile(): ChatData {
181
- // 如果已有缓存,直接返回
182
- if (this.memoryCache) {
183
- return this.memoryCache
184
- }
185
-
186
- // 异步加载数据
187
- process.nextTick(() => {
188
- try {
189
- const metadata = this.readMetadata()
190
- const messages = this.loadAllChannelMessages()
191
- this.memoryCache = {
192
- ...metadata,
193
- messages
194
- }
195
- } catch (error) {
196
- this.logger.error('读取聊天数据失败:', error)
197
- }
198
- })
199
-
200
- // 先返回空数据
201
- this.memoryCache = {
202
- bots: {},
203
- channels: {},
204
- messages: {},
205
- pinnedBots: [],
206
- pinnedChannels: []
207
- }
208
- return this.memoryCache
209
- }
210
-
211
- writeChatDataToFile(data: ChatData) {
212
- data.lastSaveTime = Date.now()
213
- this.memoryCache = data
214
-
215
- process.nextTick(() => {
216
- try {
217
- // 写入元数据
218
- const { messages, ...metadata } = data
219
- this.writeMetadata(metadata)
220
-
221
- // 写入各个频道的消息
222
- for (const [channelKey, channelMessages] of Object.entries(messages)) {
223
- const [selfId, channelId] = channelKey.split(':')
224
- if (selfId && channelId) {
225
- this.writeChannelMessages(selfId, channelId, channelMessages)
226
- }
227
- }
228
- } catch (error) {
229
- this.logger.error('写入聊天数据失败:', error)
230
- }
231
- })
232
- }
233
-
234
313
  // 为特定频道安排写入
235
314
  private scheduleWrite(channelKey: string) {
236
315
  // 取消之前的定时器
@@ -241,56 +320,42 @@ export class FileManager {
241
320
 
242
321
  // 创建新的定时器
243
322
  const timer = this.ctx.setTimeout(() => {
244
- process.nextTick(() => {
245
- this.flushPendingMessages(channelKey)
246
- })
247
323
  this.writeTimers.delete(channelKey)
324
+ void this.flushPendingMessages(channelKey)
248
325
  }, this.WRITE_DEBOUNCE_MS)
249
326
 
250
327
  this.writeTimers.set(channelKey, timer)
251
328
  }
252
329
 
253
330
  // 刷新特定频道的待写入消息
254
- private flushPendingMessages(channelKey: string) {
331
+ private async flushPendingMessages(channelKey: string) {
255
332
  const messagesToWrite = this.pendingMessages.get(channelKey)
256
333
  if (!messagesToWrite || messagesToWrite.length === 0) return
257
334
 
258
335
  this.pendingMessages.delete(channelKey)
259
336
 
260
- const data = this.memoryCache || this.readChatDataFromFile()
337
+ const cachedMessages = this.peekCachedChannelMessages(channelKey)
261
338
  const [selfId, channelId] = channelKey.split(':')
262
339
 
263
340
  if (!selfId || !channelId) return
264
341
 
265
- if (!data.messages[channelKey]) {
266
- data.messages[channelKey] = []
267
- }
268
-
269
- for (const messageInfo of messagesToWrite) {
270
- const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
271
- if (existingMessage) {
272
- continue
273
- }
274
-
275
- if (!messageInfo.timestamp) {
276
- messageInfo.timestamp = Date.now()
277
- }
278
-
279
- const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
280
- data.messages[channelKey].push(cleanedMessageInfo)
342
+ const uniqueMessages = this.deduplicateMessages(messagesToWrite)
343
+ if (!uniqueMessages.length) {
344
+ return
281
345
  }
282
346
 
283
- // 限制消息数量
284
- if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
285
- data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
286
- data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
347
+ if (cachedMessages) {
348
+ const nextMessages = this.mergeChannelMessages(cachedMessages, uniqueMessages)
349
+ this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
287
350
  }
288
351
 
289
- // 只写入这个频道的消息文件
290
- this.writeChannelMessages(selfId, channelId, data.messages[channelKey])
291
- this.memoryCache = data
352
+ this.rememberRecentMessageIds(channelKey, uniqueMessages.map((message) => message.id))
292
353
 
293
- this.logInfo(`批量写入 ${messagesToWrite.length} 条消息到频道 ${channelKey}`)
354
+ // 只向最后一个 chunk 追加,避免整频道重写。
355
+ this.enqueueWrite(async () => {
356
+ await this.appendMessagesToChannel(selfId, channelId, uniqueMessages)
357
+ this.logger.logInfo(`批量写入 ${uniqueMessages.length} 条消息到频道 ${channelKey}`)
358
+ })
294
359
  }
295
360
 
296
361
  cleanExcessMessages(data: ChatData): ChatData {
@@ -303,14 +368,14 @@ export class FileManager {
303
368
  const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
304
369
  cleanedCount += messages.length - keptMessages.length
305
370
  cleanedMessages[channelKey] = keptMessages
306
- this.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
371
+ this.logger.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
307
372
  } else {
308
373
  cleanedMessages[channelKey] = messages
309
374
  }
310
375
  }
311
376
 
312
377
  if (cleanedCount > 0) {
313
- this.logInfo('总共清理超量消息:', cleanedCount, '条')
378
+ this.logger.logInfo('总共清理超量消息:', cleanedCount, '条')
314
379
  }
315
380
 
316
381
  return {
@@ -320,53 +385,1020 @@ export class FileManager {
320
385
  }
321
386
 
322
387
  async addMessageToFile(messageInfo: MessageInfo) {
388
+ await this.ensureMetadataLoaded()
323
389
  const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
324
390
 
325
- // 添加到待写入队列
326
- if (!this.pendingMessages.has(channelKey)) {
327
- this.pendingMessages.set(channelKey, [])
391
+ if (!messageInfo.timestamp) {
392
+ messageInfo.timestamp = Date.now()
328
393
  }
329
- this.pendingMessages.get(channelKey)!.push(messageInfo)
330
394
 
331
- // 更新内存缓存
332
- const data = this.memoryCache || this.readChatDataFromFile()
395
+ const cleanedMessageInfo = await this.utils.cleanBase64ContentAsync(messageInfo, false)
396
+ const pendingMessages = this.pendingMessages.get(channelKey) || []
397
+ if (pendingMessages.some((message) => message.id === cleanedMessageInfo.id)) {
398
+ return
399
+ }
333
400
 
334
- if (!data.messages[channelKey]) {
335
- data.messages[channelKey] = []
401
+ const cachedMessages = this.peekCachedChannelMessages(channelKey)
402
+ if (cachedMessages?.some((message) => message.id === cleanedMessageInfo.id)) {
403
+ return
336
404
  }
337
405
 
338
- const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
339
- if (!existingMessage) {
340
- if (!messageInfo.timestamp) {
341
- messageInfo.timestamp = Date.now()
406
+ if (!cachedMessages) {
407
+ if (this.hasRecentMessageId(channelKey, cleanedMessageInfo.id)) {
408
+ return
342
409
  }
343
- const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
344
- data.messages[channelKey].push(cleanedMessageInfo)
345
410
 
346
- if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
347
- data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
348
- data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
411
+ const existsInStorage = await this.channelMessageExists(messageInfo.selfId, messageInfo.channelId, cleanedMessageInfo.id)
412
+ if (existsInStorage) {
413
+ return
349
414
  }
415
+ }
416
+
417
+ pendingMessages.push(cleanedMessageInfo)
418
+ this.pendingMessages.set(channelKey, pendingMessages)
419
+ this.rememberRecentMessageIds(channelKey, [cleanedMessageInfo.id])
350
420
 
351
- this.memoryCache = data
421
+ if (cleanedMessageInfo.type === 'bot' && cleanedMessageInfo.sending) {
422
+ this.registerPendingBotMessage(channelKey, cleanedMessageInfo.id)
423
+ }
424
+
425
+ if (cachedMessages) {
426
+ const nextMessages = this.mergeChannelMessages(cachedMessages, [cleanedMessageInfo])
427
+ this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
352
428
  }
353
429
 
354
430
  // 安排写入
355
431
  this.scheduleWrite(channelKey)
356
432
  }
357
433
 
358
- dispose() {
434
+ async cleanupExcessMessagesInStorage() {
435
+ let cleanedCount = 0
436
+
437
+ for (const channelKey of [...this.dirtyChannelKeys]) {
438
+ const [selfId, channelId] = channelKey.split(':')
439
+ if (!selfId || !channelId) {
440
+ this.dirtyChannelKeys.delete(channelKey)
441
+ continue
442
+ }
443
+
444
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
445
+ if (indexData.totalMessages <= this.config.maxMessagesPerChannel) {
446
+ this.dirtyChannelKeys.delete(channelKey)
447
+ continue
448
+ }
449
+
450
+ const removedCount = await this.trimChannelToLimit(selfId, channelId, indexData, this.config.maxMessagesPerChannel)
451
+ if (!removedCount) {
452
+ continue
453
+ }
454
+
455
+ cleanedCount += removedCount
456
+ if (this.peekCachedChannelMessages(channelKey)) {
457
+ this.setCachedChannelMessages(channelKey, await this.loadChannelMessagesNoCache(selfId, channelId))
458
+ }
459
+ this.dirtyChannelKeys.delete(channelKey)
460
+ this.logger.logInfo(`频道 ${channelKey} 清理了 ${removedCount} 条旧消息,保留最新 ${indexData.totalMessages} 条`)
461
+ }
462
+
463
+ if (cleanedCount > 0) {
464
+ this.logger.logInfo('定期清理完成,清理了', cleanedCount, '条超量消息')
465
+ }
466
+ }
467
+
468
+ async getAllChannelMessageCounts(): Promise<Record<string, number>> {
469
+ const counts: Record<string, number> = {}
470
+
471
+ await this.scanStoredChannels(async (entry) => {
472
+ counts[entry.channelKey] = await this.countChannelMessages(entry.selfId, entry.channelId)
473
+ })
474
+
475
+ return counts
476
+ }
477
+
478
+ async deleteChannelData(selfId: string, channelId: string) {
479
+ await this.ensureMetadataLoaded()
480
+
481
+ const channelKey = `${selfId}:${channelId}`
482
+ const deletedMessages = await this.countChannelMessages(selfId, channelId)
483
+
484
+ this.deleteCachedChannelMessages(channelKey)
485
+ this.deleteRecentMessageIds(channelKey)
486
+ this.deletePendingBotMessages(channelKey)
487
+ this.deleteMessageChunkLocations(channelKey)
488
+ this.dirtyChannelKeys.delete(channelKey)
489
+ this.pendingMessages.delete(channelKey)
490
+
491
+ const timer = this.writeTimers.get(channelKey)
492
+ if (timer) {
493
+ timer()
494
+ this.writeTimers.delete(channelKey)
495
+ }
496
+
497
+ if (this.memoryCache.channels[selfId]?.[channelId]) {
498
+ delete this.memoryCache.channels[selfId][channelId]
499
+ if (!Object.keys(this.memoryCache.channels[selfId]).length) {
500
+ delete this.memoryCache.channels[selfId]
501
+ }
502
+ }
503
+
504
+ this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => item !== channelKey)
505
+
506
+ await this.removeChannelStorage(selfId, channelId)
507
+
508
+ this.scheduleMetadataWrite()
509
+
510
+ return { deletedMessages }
511
+ }
512
+
513
+ async deleteBotData(selfId: string) {
514
+ await this.ensureMetadataLoaded()
515
+
516
+ const botDir = path.join(this.chatHistoryDir, selfId)
517
+ const channels = await this.listBotChannels(selfId)
518
+ let deletedMessages = 0
519
+
520
+ for (const entry of channels) {
521
+ deletedMessages += await this.countChannelMessages(entry.selfId, entry.channelId)
522
+ this.deleteCachedChannelMessages(entry.channelKey)
523
+ this.deleteRecentMessageIds(entry.channelKey)
524
+ this.deletePendingBotMessages(entry.channelKey)
525
+ this.deleteMessageChunkLocations(entry.channelKey)
526
+ this.dirtyChannelKeys.delete(entry.channelKey)
527
+ this.pendingMessages.delete(entry.channelKey)
528
+
529
+ const timer = this.writeTimers.get(entry.channelKey)
530
+ if (timer) {
531
+ timer()
532
+ this.writeTimers.delete(entry.channelKey)
533
+ }
534
+ }
535
+
536
+ const deletedChannels = channels.length
537
+
538
+ delete this.memoryCache.bots[selfId]
539
+ delete this.memoryCache.channels[selfId]
540
+ this.memoryCache.pinnedBots = this.memoryCache.pinnedBots.filter((item) => item !== selfId)
541
+ this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => !item.startsWith(`${selfId}:`))
542
+
543
+ try {
544
+ await fs.rm(botDir, { recursive: true, force: true })
545
+ } catch (error) {
546
+ this.logger.error(`删除机器人目录失败 [${selfId}]:`, error)
547
+ }
548
+
549
+ this.scheduleMetadataWrite()
550
+
551
+ return {
552
+ deletedChannels,
553
+ deletedMessages
554
+ }
555
+ }
556
+
557
+ async updateUserProfileInBotData(selfId: string, userId: string, userName?: string, avatar?: string) {
558
+ await this.ensureMetadataLoaded()
559
+
560
+ let changed = false
561
+
562
+ const botChannels = this.memoryCache.channels[selfId] || {}
563
+ const possibleChannelIds = [
564
+ userId,
565
+ `private:${userId}`,
566
+ `direct:${userId}`
567
+ ]
568
+
569
+ for (const channelId of possibleChannelIds) {
570
+ const channel = botChannels[channelId]
571
+ if (!channel || !channel.isDirect || !userName) {
572
+ continue
573
+ }
574
+
575
+ const newName = `私聊(${userName})`
576
+ if (channel.name !== newName) {
577
+ channel.name = newName
578
+ changed = true
579
+ }
580
+ }
581
+
582
+ const channels = await this.listBotChannels(selfId)
583
+ for (const entry of channels) {
584
+ const entryChanged = await this.updateUserProfileInChannel(entry.selfId, entry.channelId, userId, userName, avatar)
585
+ if (!entryChanged) {
586
+ continue
587
+ }
588
+
589
+ if (this.peekCachedChannelMessages(entry.channelKey)) {
590
+ this.setCachedChannelMessages(entry.channelKey, await this.loadChannelMessagesNoCache(entry.selfId, entry.channelId))
591
+ }
592
+ changed = true
593
+ }
594
+
595
+ if (changed) {
596
+ this.scheduleMetadataWrite()
597
+ }
598
+
599
+ return changed
600
+ }
601
+
602
+ async markLatestBotMessageAsSent(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
603
+ const channelKey = `${selfId}:${channelId}`
604
+ const tempMessageId = this.peekLatestPendingBotMessageId(channelKey)
605
+
606
+ let matched: MessageInfo | undefined
607
+ if (tempMessageId) {
608
+ matched = await this.findAndUpdateBotMessageByTempId(selfId, channelId, tempMessageId, realId)
609
+ this.consumePendingBotMessageId(channelKey, tempMessageId)
610
+ }
611
+
612
+ if (!matched) {
613
+ matched = await this.findAndUpdateLatestBotMessage(selfId, channelId, realId)
614
+ }
615
+
616
+ if (!matched) {
617
+ return undefined
618
+ }
619
+
620
+ const cachedMessages = this.peekCachedChannelMessages(channelKey)
621
+ if (cachedMessages) {
622
+ const cachedMatched = cachedMessages.find((message) => message.id === matched?.id)
623
+ if (cachedMatched) {
624
+ cachedMatched.realId = realId
625
+ cachedMatched.sending = false
626
+ this.setCachedChannelMessages(channelKey, cachedMessages)
627
+ }
628
+ }
629
+
630
+ return matched
631
+ }
632
+
633
+ async dispose() {
634
+ this.disposed = true
635
+
359
636
  // 取消所有定时器
360
637
  for (const [channelKey, timer] of this.writeTimers.entries()) {
361
638
  timer()
362
- this.flushPendingMessages(channelKey)
639
+ await this.flushPendingMessages(channelKey)
363
640
  }
364
641
  this.writeTimers.clear()
642
+
643
+ await this.writeQueue
644
+ await this.utils.dispose()
365
645
  }
366
646
 
367
- private logInfo(...args: any[]) {
368
- if (this.config.loggerinfo) {
369
- (this.logger.info as (...args: any[]) => void)(...args)
647
+ private createEmptyChatData(): ChatData {
648
+ return {
649
+ bots: {},
650
+ channels: {},
651
+ messages: {},
652
+ pinnedBots: [],
653
+ pinnedChannels: []
654
+ }
655
+ }
656
+
657
+ private async ensureMetadataLoaded() {
658
+ if (!this.metadataLoadPromise) {
659
+ this.metadataLoadPromise = this.loadMetadataIntoCache()
660
+ }
661
+
662
+ await this.metadataLoadPromise
663
+ }
664
+
665
+ private async loadMetadataIntoCache() {
666
+ const metadata = await this.readMetadata()
667
+ this.memoryCache = {
668
+ ...metadata,
669
+ messages: this.getChannelMessagesCacheSnapshot()
370
670
  }
371
671
  }
672
+
673
+ private async loadChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
674
+ const messages = await this.loadChannelMessagesNoCache(selfId, channelId)
675
+ this.setCachedChannelMessages(`${selfId}:${channelId}`, messages)
676
+ return messages
677
+ }
678
+
679
+ private async loadChannelMessagesNoCache(selfId: string, channelId: string): Promise<MessageInfo[]> {
680
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
681
+ const channelDirPath = this.getChannelDirPath(selfId, channelId)
682
+ const messages: MessageInfo[] = []
683
+ const channelKey = `${selfId}:${channelId}`
684
+
685
+ for (const chunk of indexData.chunks) {
686
+ const chunkMessages = await this.readChunkMessages(channelDirPath, chunk.fileName)
687
+ messages.push(...chunkMessages)
688
+ this.rememberMessageChunkLocation(channelKey, chunk.fileName, chunkMessages.map((message) => message.id))
689
+ }
690
+
691
+ this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
692
+
693
+ return messages
694
+ }
695
+
696
+ private scheduleMetadataWrite() {
697
+ this.enqueueWrite(async () => {
698
+ const { messages, ...metadata } = this.readChatDataFromFile()
699
+ await this.writeMetadata(metadata)
700
+ })
701
+ }
702
+
703
+ private enqueueWrite(task: () => Promise<void>) {
704
+ if (this.disposed) {
705
+ return
706
+ }
707
+
708
+ this.writeQueue = this.writeQueue
709
+ .then(task)
710
+ .catch((error) => {
711
+ this.logger.error('写入任务失败:', error)
712
+ })
713
+ }
714
+
715
+ private async listStoredChannels(): Promise<StoredChannelEntry[]> {
716
+ const result: StoredChannelEntry[] = []
717
+
718
+ await this.scanStoredChannels(async (entry) => {
719
+ result.push(entry)
720
+ })
721
+
722
+ return result
723
+ }
724
+
725
+ private async listBotChannels(selfId: string): Promise<StoredChannelEntry[]> {
726
+ const result: StoredChannelEntry[] = []
727
+
728
+ await this.scanStoredChannels(async (entry) => {
729
+ if (entry.selfId === selfId) {
730
+ result.push(entry)
731
+ }
732
+ }, selfId)
733
+
734
+ return result
735
+ }
736
+
737
+ private async scanStoredChannels(visitor: (entry: StoredChannelEntry) => Promise<void>, botIdFilter?: string) {
738
+ try {
739
+ const botDirs = await fs.readdir(this.chatHistoryDir, { withFileTypes: true })
740
+ for (const botEntry of botDirs) {
741
+ const botName = this.normalizeDirentName(botEntry.name)
742
+ if (!botEntry.isDirectory()) continue
743
+ if (botIdFilter && botName !== botIdFilter) continue
744
+
745
+ const botDir = path.join(this.chatHistoryDir, botName)
746
+ const channelEntries = await fs.readdir(botDir, { withFileTypes: true })
747
+
748
+ for (const channelEntry of channelEntries) {
749
+ const channelId = await this.resolveStoredChannelId(botName, channelEntry)
750
+ if (!channelId) {
751
+ continue
752
+ }
753
+
754
+ await visitor(this.createStoredChannelEntry(botName, channelId))
755
+ }
756
+ }
757
+ } catch (error) {
758
+ if (!this.isFileMissingError(error)) {
759
+ this.logger.error('扫描频道文件失败:', error)
760
+ }
761
+ }
762
+ }
763
+
764
+ private createStoredChannelEntry(selfId: string, channelId: string): StoredChannelEntry {
765
+ const channelDirPath = this.getChannelDirPath(selfId, channelId)
766
+ return {
767
+ selfId,
768
+ channelId,
769
+ channelKey: `${selfId}:${channelId}`,
770
+ channelDirPath,
771
+ indexFilePath: this.getChannelIndexPath(selfId, channelId)
772
+ }
773
+ }
774
+
775
+ private async resolveStoredChannelId(selfId: string, entry: { isFile(): boolean, isDirectory(): boolean, name: string | Buffer }) {
776
+ const entryName = this.normalizeDirentName(entry.name)
777
+
778
+ if (!entry.isDirectory()) {
779
+ return undefined
780
+ }
781
+
782
+ const encodedChannelId = entryName
783
+ const channelDirPath = path.join(this.chatHistoryDir, selfId, encodedChannelId)
784
+ const indexFilePath = path.join(channelDirPath, 'index.json')
785
+
786
+ try {
787
+ const jsonData = await fs.readFile(indexFilePath, 'utf8')
788
+ const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
789
+ if (typeof indexData.channelId === 'string') {
790
+ return indexData.channelId
791
+ }
792
+ } catch (error) {
793
+ if (!this.isFileMissingError(error)) {
794
+ this.logger.error(`读取频道索引失败 [${selfId}:${entryName}]:`, error)
795
+ }
796
+ }
797
+
798
+ return this.decodeChannelId(encodedChannelId)
799
+ }
800
+
801
+ private getBotDirPath(selfId: string) {
802
+ return path.join(this.chatHistoryDir, selfId)
803
+ }
804
+
805
+ private getEncodedChannelId(channelId: string) {
806
+ return encodeURIComponent(channelId)
807
+ }
808
+
809
+ private decodeChannelId(encodedChannelId: string) {
810
+ try {
811
+ return decodeURIComponent(encodedChannelId)
812
+ } catch {
813
+ return encodedChannelId
814
+ }
815
+ }
816
+
817
+ private normalizeDirentName(name: string | Buffer) {
818
+ return typeof name === 'string' ? name : name.toString('utf8')
819
+ }
820
+
821
+ private getChannelDirPath(selfId: string, channelId: string) {
822
+ return path.join(this.getBotDirPath(selfId), this.getEncodedChannelId(channelId))
823
+ }
824
+
825
+ private getChannelIndexPath(selfId: string, channelId: string) {
826
+ return path.join(this.getChannelDirPath(selfId, channelId), 'index.json')
827
+ }
828
+
829
+ private getChunkFilePath(channelDirPath: string, fileName: string) {
830
+ return path.join(channelDirPath, fileName)
831
+ }
832
+
833
+ private createEmptyChannelIndex(channelId: string): ChannelIndexData {
834
+ return {
835
+ version: 1,
836
+ channelId,
837
+ totalMessages: 0,
838
+ nextChunkId: 1,
839
+ chunks: []
840
+ }
841
+ }
842
+
843
+ private createChunkFileName(chunkId: number) {
844
+ return `chunk-${String(chunkId).padStart(6, '0')}.json`
845
+ }
846
+
847
+ private async loadOrCreateChannelIndex(selfId: string, channelId: string): Promise<ChannelIndexData> {
848
+ const entry = this.createStoredChannelEntry(selfId, channelId)
849
+
850
+ try {
851
+ const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
852
+ const indexData = JSON.parse(jsonData) as ChannelIndexData
853
+ const normalizedIndexData = this.normalizeChannelIndex(channelId, indexData)
854
+ this.syncDirtyChannelState(entry.channelKey, normalizedIndexData.totalMessages)
855
+ return normalizedIndexData
856
+ } catch (error) {
857
+ if (!this.isFileMissingError(error)) {
858
+ this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
859
+ }
860
+ }
861
+
862
+ const indexData = this.createEmptyChannelIndex(channelId)
863
+ await this.ensureDir(entry.channelDirPath)
864
+
865
+ await this.writeChannelIndex(entry.indexFilePath, indexData)
866
+ this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
867
+
868
+ return indexData
869
+ }
870
+
871
+ private normalizeChannelIndex(channelId: string, indexData: ChannelIndexData): ChannelIndexData {
872
+ return {
873
+ version: 1,
874
+ channelId,
875
+ totalMessages: indexData.totalMessages || 0,
876
+ nextChunkId: indexData.nextChunkId || (indexData.chunks?.length || 0) + 1,
877
+ chunks: Array.isArray(indexData.chunks) ? indexData.chunks : []
878
+ }
879
+ }
880
+
881
+ private async writeChannelIndex(indexFilePath: string, indexData: ChannelIndexData) {
882
+ await this.ensureDir(path.dirname(indexFilePath))
883
+ await fs.writeFile(indexFilePath, JSON.stringify(indexData, null, 2), 'utf8')
884
+ }
885
+
886
+ private async readChunkMessages(channelDirPath: string, fileName: string): Promise<MessageInfo[]> {
887
+ try {
888
+ const jsonData = await fs.readFile(this.getChunkFilePath(channelDirPath, fileName), 'utf8')
889
+ const messages = JSON.parse(jsonData)
890
+ return Array.isArray(messages) ? messages : []
891
+ } catch (error) {
892
+ if (!this.isFileMissingError(error)) {
893
+ this.logger.error(`读取消息分块失败 [${channelDirPath}/${fileName}]:`, error)
894
+ }
895
+ return []
896
+ }
897
+ }
898
+
899
+ private async writeChunkMessages(channelDirPath: string, fileName: string, messages: MessageInfo[]) {
900
+ await this.ensureDir(channelDirPath)
901
+ await fs.writeFile(this.getChunkFilePath(channelDirPath, fileName), JSON.stringify(messages, null, 2), 'utf8')
902
+ }
903
+
904
+ private async writeMessagesToChunks(channelDirPath: string, indexData: ChannelIndexData, messages: MessageInfo[]) {
905
+ const oldChunks = [...indexData.chunks]
906
+ indexData.chunks = []
907
+ indexData.totalMessages = 0
908
+ indexData.nextChunkId = 1
909
+
910
+ for (let offset = 0; offset < messages.length; offset += this.config.messageChunkSize) {
911
+ const chunkMessages = messages.slice(offset, offset + this.config.messageChunkSize)
912
+ const chunkId = indexData.nextChunkId
913
+ const fileName = this.createChunkFileName(chunkId)
914
+ await this.writeChunkMessages(channelDirPath, fileName, chunkMessages)
915
+ indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
916
+ indexData.nextChunkId += 1
917
+ indexData.totalMessages += chunkMessages.length
918
+ }
919
+
920
+ for (const chunk of oldChunks) {
921
+ try {
922
+ await fs.unlink(this.getChunkFilePath(channelDirPath, chunk.fileName))
923
+ } catch (error) {
924
+ if (!this.isFileMissingError(error)) {
925
+ this.logger.warn(`删除旧消息分块失败 [${channelDirPath}/${chunk.fileName}]:`, error)
926
+ }
927
+ }
928
+ }
929
+
930
+ await this.writeChannelIndex(path.join(channelDirPath, 'index.json'), indexData)
931
+ }
932
+
933
+ private async appendMessagesToChannel(selfId: string, channelId: string, messages: MessageInfo[]) {
934
+ if (!messages.length) {
935
+ return
936
+ }
937
+
938
+ const entry = this.createStoredChannelEntry(selfId, channelId)
939
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
940
+ await this.ensureDir(entry.channelDirPath)
941
+
942
+ let remainingMessages = [...messages]
943
+ const lastChunk = indexData.chunks[indexData.chunks.length - 1]
944
+
945
+ if (lastChunk && lastChunk.messageCount < this.config.messageChunkSize) {
946
+ const chunkMessages = await this.readChunkMessages(entry.channelDirPath, lastChunk.fileName)
947
+ const writableCount = this.config.messageChunkSize - chunkMessages.length
948
+ const appendMessages = remainingMessages.slice(0, writableCount)
949
+ if (appendMessages.length) {
950
+ chunkMessages.push(...appendMessages)
951
+ lastChunk.messageCount = chunkMessages.length
952
+ indexData.totalMessages += appendMessages.length
953
+ remainingMessages = remainingMessages.slice(appendMessages.length)
954
+ await this.writeChunkMessages(entry.channelDirPath, lastChunk.fileName, chunkMessages)
955
+ this.rememberMessageChunkLocation(entry.channelKey, lastChunk.fileName, appendMessages.map((message) => message.id))
956
+ }
957
+ }
958
+
959
+ while (remainingMessages.length) {
960
+ const chunkMessages = remainingMessages.slice(0, this.config.messageChunkSize)
961
+ const chunkId = indexData.nextChunkId
962
+ const fileName = this.createChunkFileName(chunkId)
963
+ await this.writeChunkMessages(entry.channelDirPath, fileName, chunkMessages)
964
+ indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
965
+ indexData.nextChunkId += 1
966
+ indexData.totalMessages += chunkMessages.length
967
+ this.rememberMessageChunkLocation(entry.channelKey, fileName, chunkMessages.map((message) => message.id))
968
+ remainingMessages = remainingMessages.slice(chunkMessages.length)
969
+ }
970
+
971
+ this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
972
+ await this.writeChannelIndex(entry.indexFilePath, indexData)
973
+ }
974
+
975
+ private async trimChannelToLimit(selfId: string, channelId: string, indexData: ChannelIndexData, limit: number) {
976
+ const entry = this.createStoredChannelEntry(selfId, channelId)
977
+ let overflow = indexData.totalMessages - limit
978
+ if (overflow <= 0) {
979
+ return 0
980
+ }
981
+
982
+ let removedCount = 0
983
+
984
+ while (overflow > 0 && indexData.chunks.length) {
985
+ const chunk = indexData.chunks[0]
986
+ if (chunk.messageCount <= overflow) {
987
+ const removedChunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
988
+ overflow -= chunk.messageCount
989
+ removedCount += chunk.messageCount
990
+ indexData.totalMessages -= chunk.messageCount
991
+ indexData.chunks.shift()
992
+ this.forgetRecentMessageIds(entry.channelKey, removedChunkMessages.map((message) => message.id))
993
+ this.forgetMessageChunkLocation(entry.channelKey, removedChunkMessages.map((message) => message.id))
994
+ try {
995
+ await fs.unlink(this.getChunkFilePath(entry.channelDirPath, chunk.fileName))
996
+ } catch (error) {
997
+ if (!this.isFileMissingError(error)) {
998
+ this.logger.warn(`删除消息分块失败 [${entry.channelKey}:${chunk.fileName}]:`, error)
999
+ }
1000
+ }
1001
+ continue
1002
+ }
1003
+
1004
+ const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1005
+ const keptMessages = chunkMessages.slice(overflow)
1006
+ const removedMessages = chunkMessages.slice(0, overflow)
1007
+ removedCount += overflow
1008
+ indexData.totalMessages -= overflow
1009
+ chunk.messageCount = keptMessages.length
1010
+ overflow = 0
1011
+ this.forgetRecentMessageIds(entry.channelKey, removedMessages.map((message) => message.id))
1012
+ this.forgetMessageChunkLocation(entry.channelKey, removedMessages.map((message) => message.id))
1013
+ await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, keptMessages)
1014
+ }
1015
+
1016
+ this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
1017
+ await this.writeChannelIndex(entry.indexFilePath, indexData)
1018
+ return removedCount
1019
+ }
1020
+
1021
+ private async countChannelMessages(selfId: string, channelId: string) {
1022
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1023
+
1024
+ try {
1025
+ const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
1026
+ const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
1027
+ if (typeof indexData.totalMessages === 'number') {
1028
+ return indexData.totalMessages
1029
+ }
1030
+ } catch (error) {
1031
+ if (!this.isFileMissingError(error)) {
1032
+ this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
1033
+ }
1034
+ }
1035
+
1036
+ return 0
1037
+ }
1038
+
1039
+ private async removeChannelStorage(selfId: string, channelId: string) {
1040
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1041
+
1042
+ try {
1043
+ await fs.rm(entry.channelDirPath, { recursive: true, force: true })
1044
+ } catch (error) {
1045
+ this.logger.error(`删除频道目录失败 [${entry.channelKey}]:`, error)
1046
+ }
1047
+ }
1048
+
1049
+ private async updateUserProfileInChannel(selfId: string, channelId: string, userId: string, userName?: string, avatar?: string) {
1050
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1051
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1052
+ let changed = false
1053
+
1054
+ for (const chunk of indexData.chunks) {
1055
+ const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1056
+ let chunkChanged = false
1057
+
1058
+ for (const message of messages) {
1059
+ if (message.userId !== userId) {
1060
+ continue
1061
+ }
1062
+
1063
+ if (userName && message.username !== userName) {
1064
+ message.username = userName
1065
+ chunkChanged = true
1066
+ }
1067
+
1068
+ if (avatar && message.avatar !== avatar) {
1069
+ message.avatar = avatar
1070
+ chunkChanged = true
1071
+ }
1072
+ }
1073
+
1074
+ if (!chunkChanged) {
1075
+ continue
1076
+ }
1077
+
1078
+ await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
1079
+ changed = true
1080
+ }
1081
+
1082
+ return changed
1083
+ }
1084
+
1085
+ private async findAndUpdateLatestBotMessage(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
1086
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1087
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1088
+
1089
+ for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1090
+ const chunk = indexData.chunks[index]
1091
+ const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1092
+ const matched = [...messages].reverse().find((message) => message.type === 'bot' && message.sending)
1093
+ if (!matched) {
1094
+ continue
1095
+ }
1096
+
1097
+ matched.realId = realId
1098
+ matched.sending = false
1099
+ await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
1100
+ return matched
1101
+ }
1102
+
1103
+ return undefined
1104
+ }
1105
+
1106
+ private async findAndUpdateBotMessageByTempId(selfId: string, channelId: string, tempMessageId: string, realId: string): Promise<MessageInfo | undefined> {
1107
+ const channelKey = `${selfId}:${channelId}`
1108
+ const pendingMessages = this.pendingMessages.get(channelKey)
1109
+ const pendingMatched = pendingMessages?.find((message) => message.id === tempMessageId)
1110
+ if (pendingMatched) {
1111
+ pendingMatched.realId = realId
1112
+ pendingMatched.sending = false
1113
+ return pendingMatched
1114
+ }
1115
+
1116
+ const cachedMessages = this.peekCachedChannelMessages(channelKey)
1117
+ const cachedMatched = cachedMessages?.find((message) => message.id === tempMessageId)
1118
+ if (cachedMatched) {
1119
+ cachedMatched.realId = realId
1120
+ cachedMatched.sending = false
1121
+ this.setCachedChannelMessages(channelKey, cachedMessages)
1122
+ }
1123
+
1124
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1125
+ const chunkFileName = this.getMessageChunkLocation(channelKey, tempMessageId)
1126
+ if (chunkFileName) {
1127
+ const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunkFileName)
1128
+ const matched = chunkMessages.find((message) => message.id === tempMessageId)
1129
+ if (matched) {
1130
+ matched.realId = realId
1131
+ matched.sending = false
1132
+ await this.writeChunkMessages(entry.channelDirPath, chunkFileName, chunkMessages)
1133
+ return matched
1134
+ }
1135
+ }
1136
+
1137
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1138
+ for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1139
+ const chunk = indexData.chunks[index]
1140
+ const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1141
+ const matched = chunkMessages.find((message) => message.id === tempMessageId)
1142
+ if (!matched) {
1143
+ continue
1144
+ }
1145
+
1146
+ matched.realId = realId
1147
+ matched.sending = false
1148
+ await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, chunkMessages)
1149
+ this.rememberMessageChunkLocation(channelKey, chunk.fileName, [matched.id])
1150
+ return matched
1151
+ }
1152
+
1153
+ return cachedMatched
1154
+ }
1155
+
1156
+ private deduplicateMessages(messages: MessageInfo[]) {
1157
+ const seen = new Set<string>()
1158
+ const deduplicated: MessageInfo[] = []
1159
+
1160
+ for (const message of messages) {
1161
+ if (seen.has(message.id)) {
1162
+ continue
1163
+ }
1164
+ seen.add(message.id)
1165
+ deduplicated.push(message)
1166
+ }
1167
+
1168
+ return deduplicated
1169
+ }
1170
+
1171
+ private getCachedChannelMessages(channelKey: string) {
1172
+ const cached = this.channelMessagesCache.get(channelKey)
1173
+ if (!cached) {
1174
+ return undefined
1175
+ }
1176
+
1177
+ this.channelMessagesCache.delete(channelKey)
1178
+ this.channelMessagesCache.set(channelKey, cached)
1179
+ this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
1180
+ return cached
1181
+ }
1182
+
1183
+ private peekCachedChannelMessages(channelKey: string) {
1184
+ return this.channelMessagesCache.get(channelKey)
1185
+ }
1186
+
1187
+ private setCachedChannelMessages(channelKey: string, messages: MessageInfo[]) {
1188
+ this.channelMessagesCache.delete(channelKey)
1189
+ this.channelMessagesCache.set(channelKey, messages)
1190
+ this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
1191
+
1192
+ while (this.channelMessagesCache.size > this.config.channelCacheLimit) {
1193
+ const oldestKey = this.channelMessagesCache.keys().next().value as string | undefined
1194
+ if (!oldestKey) {
1195
+ break
1196
+ }
1197
+ this.channelMessagesCache.delete(oldestKey)
1198
+ this.deleteRecentMessageIds(oldestKey)
1199
+ this.deleteMessageChunkLocations(oldestKey)
1200
+ }
1201
+
1202
+ this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
1203
+ }
1204
+
1205
+ private deleteCachedChannelMessages(channelKey: string) {
1206
+ this.channelMessagesCache.delete(channelKey)
1207
+ delete this.memoryCache.messages[channelKey]
1208
+ }
1209
+
1210
+ private getChannelMessagesCacheSnapshot() {
1211
+ return Object.fromEntries(this.channelMessagesCache.entries())
1212
+ }
1213
+
1214
+ private mergeChannelMessages(baseMessages: MessageInfo[], appendedMessages: MessageInfo[]) {
1215
+ const merged = [...baseMessages]
1216
+ const knownIds = new Set(baseMessages.map((message) => message.id))
1217
+
1218
+ for (const message of appendedMessages) {
1219
+ if (knownIds.has(message.id)) {
1220
+ continue
1221
+ }
1222
+ knownIds.add(message.id)
1223
+ merged.push(message)
1224
+ }
1225
+
1226
+ return merged
1227
+ }
1228
+
1229
+ private limitChannelMessages(messages: MessageInfo[]) {
1230
+ if (messages.length <= this.config.maxMessagesPerChannel) {
1231
+ return messages
1232
+ }
1233
+
1234
+ return [...messages]
1235
+ .sort((left, right) => left.timestamp - right.timestamp)
1236
+ .slice(-this.config.maxMessagesPerChannel)
1237
+ }
1238
+
1239
+ private async channelMessageExists(selfId: string, channelId: string, messageId: string) {
1240
+ const entry = this.createStoredChannelEntry(selfId, channelId)
1241
+ const channelKey = entry.channelKey
1242
+
1243
+ if (this.hasRecentMessageId(channelKey, messageId)) {
1244
+ return true
1245
+ }
1246
+
1247
+ const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1248
+
1249
+ for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
1250
+ const chunk = indexData.chunks[chunkIndex]
1251
+ const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1252
+ if (messages.some((message) => message.id === messageId)) {
1253
+ this.rememberRecentMessageIds(channelKey, [messageId])
1254
+ return true
1255
+ }
1256
+ }
1257
+
1258
+ return false
1259
+ }
1260
+
1261
+ private syncDirtyChannelState(channelKey: string, totalMessages: number) {
1262
+ if (totalMessages > this.config.maxMessagesPerChannel) {
1263
+ this.dirtyChannelKeys.add(channelKey)
1264
+ return
1265
+ }
1266
+
1267
+ this.dirtyChannelKeys.delete(channelKey)
1268
+ }
1269
+
1270
+ private registerPendingBotMessage(channelKey: string, messageId: string) {
1271
+ const messageIds = this.pendingBotMessageIds.get(channelKey) || []
1272
+ messageIds.push(messageId)
1273
+ this.pendingBotMessageIds.set(channelKey, messageIds)
1274
+ }
1275
+
1276
+ private peekLatestPendingBotMessageId(channelKey: string) {
1277
+ const messageIds = this.pendingBotMessageIds.get(channelKey)
1278
+ return messageIds?.[messageIds.length - 1]
1279
+ }
1280
+
1281
+ private consumePendingBotMessageId(channelKey: string, messageId: string) {
1282
+ const messageIds = this.pendingBotMessageIds.get(channelKey)
1283
+ if (!messageIds?.length) {
1284
+ return
1285
+ }
1286
+
1287
+ const nextMessageIds = messageIds.filter((id) => id !== messageId)
1288
+ if (nextMessageIds.length) {
1289
+ this.pendingBotMessageIds.set(channelKey, nextMessageIds)
1290
+ return
1291
+ }
1292
+
1293
+ this.pendingBotMessageIds.delete(channelKey)
1294
+ }
1295
+
1296
+ private deletePendingBotMessages(channelKey: string) {
1297
+ this.pendingBotMessageIds.delete(channelKey)
1298
+ }
1299
+
1300
+ private getRecentMessageIdCacheLimit() {
1301
+ return Math.max(this.RECENT_MESSAGE_ID_CACHE_SIZE, this.config.messageChunkSize * 2)
1302
+ }
1303
+
1304
+ private rememberRecentMessageIds(channelKey: string, messageIds: string[]) {
1305
+ if (!messageIds.length) {
1306
+ return
1307
+ }
1308
+
1309
+ const nextMessageIds = [...(this.recentMessageIdsCache.get(channelKey) || [])]
1310
+ for (const messageId of messageIds) {
1311
+ const existingIndex = nextMessageIds.indexOf(messageId)
1312
+ if (existingIndex !== -1) {
1313
+ nextMessageIds.splice(existingIndex, 1)
1314
+ }
1315
+ nextMessageIds.push(messageId)
1316
+ }
1317
+
1318
+ const maxSize = this.getRecentMessageIdCacheLimit()
1319
+ this.recentMessageIdsCache.set(channelKey, nextMessageIds.slice(-maxSize))
1320
+ }
1321
+
1322
+ private forgetRecentMessageIds(channelKey: string, messageIds: string[]) {
1323
+ const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
1324
+ if (!currentMessageIds?.length || !messageIds.length) {
1325
+ return
1326
+ }
1327
+
1328
+ const nextMessageIds = currentMessageIds.filter((messageId) => !messageIds.includes(messageId))
1329
+ if (nextMessageIds.length) {
1330
+ this.recentMessageIdsCache.set(channelKey, nextMessageIds)
1331
+ return
1332
+ }
1333
+
1334
+ this.recentMessageIdsCache.delete(channelKey)
1335
+ }
1336
+
1337
+ private hasRecentMessageId(channelKey: string, messageId: string) {
1338
+ const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
1339
+ return !!currentMessageIds?.includes(messageId)
1340
+ }
1341
+
1342
+ private deleteRecentMessageIds(channelKey: string) {
1343
+ this.recentMessageIdsCache.delete(channelKey)
1344
+ }
1345
+
1346
+ private rememberMessageChunkLocation(channelKey: string, chunkFileName: string, messageIds: string[]) {
1347
+ if (!messageIds.length) {
1348
+ return
1349
+ }
1350
+
1351
+ const currentLocations = this.messageChunkLocationCache.get(channelKey) || new Map<string, string>()
1352
+ for (const messageId of messageIds) {
1353
+ currentLocations.set(messageId, chunkFileName)
1354
+ }
1355
+ this.messageChunkLocationCache.set(channelKey, currentLocations)
1356
+ }
1357
+
1358
+ private forgetMessageChunkLocation(channelKey: string, messageIds: string[]) {
1359
+ const currentLocations = this.messageChunkLocationCache.get(channelKey)
1360
+ if (!currentLocations || !messageIds.length) {
1361
+ return
1362
+ }
1363
+
1364
+ for (const messageId of messageIds) {
1365
+ currentLocations.delete(messageId)
1366
+ }
1367
+
1368
+ if (!currentLocations.size) {
1369
+ this.messageChunkLocationCache.delete(channelKey)
1370
+ }
1371
+ }
1372
+
1373
+ private getMessageChunkLocation(channelKey: string, messageId: string) {
1374
+ return this.messageChunkLocationCache.get(channelKey)?.get(messageId)
1375
+ }
1376
+
1377
+ private deleteMessageChunkLocations(channelKey: string) {
1378
+ this.messageChunkLocationCache.delete(channelKey)
1379
+ }
1380
+
1381
+ private isSameBotInfo(left: BotInfo, right: BotInfo) {
1382
+ return left.selfId === right.selfId
1383
+ && left.platform === right.platform
1384
+ && left.username === right.username
1385
+ && left.avatar === right.avatar
1386
+ && left.status === right.status
1387
+ }
1388
+
1389
+ private isSameChannelInfo(left: ChannelInfo, right: ChannelInfo) {
1390
+ return left.id === right.id
1391
+ && left.name === right.name
1392
+ && left.type === right.type
1393
+ && left.channelId === right.channelId
1394
+ && left.guildName === right.guildName
1395
+ && left.isDirect === right.isDirect
1396
+ }
1397
+
1398
+ private isFileMissingError(error: unknown): boolean {
1399
+ return typeof error === 'object'
1400
+ && error !== null
1401
+ && 'code' in error
1402
+ && error.code === 'ENOENT'
1403
+ }
372
1404
  }