koishi-plugin-chat-patch 2.3.0 → 2.4.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.
@@ -7,16 +7,16 @@ import path from 'node:path'
7
7
  import fs from 'node:fs'
8
8
 
9
9
  export class FileManager {
10
- private dataFilePath: string
11
- private fileOperationLock = Promise.resolve()
10
+ private chatHistoryDir: string // 聊天记录根目录
11
+ private metadataFilePath: string // 元数据文件路径(存储bots、channels、pinned等信息)
12
12
  private logger: Logger
13
13
  private utils: Utils
14
14
 
15
15
  private memoryCache: ChatData | null = null
16
16
 
17
- private pendingMessages: MessageInfo[] = []
17
+ private pendingMessages: Map<string, MessageInfo[]> = new Map() // 按channelKey分组的待写入消息
18
18
 
19
- private writeTimer: (() => void) | null = null
19
+ private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
20
20
 
21
21
  private readonly WRITE_DEBOUNCE_MS = 1000
22
22
 
@@ -24,45 +24,175 @@ export class FileManager {
24
24
  private ctx: Context,
25
25
  private config: Config
26
26
  ) {
27
- this.dataFilePath = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'chat-data.json')
27
+ 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')
28
30
  this.logger = ctx.logger('chat-patch')
29
31
  this.utils = new Utils(config)
30
32
 
33
+ // 清理旧版本的单文件存储
34
+ this.cleanupOldDataFiles(baseDir)
35
+
31
36
  this.memoryCache = this.readChatDataFromFile()
32
37
  }
33
38
 
34
- private ensureDataDir() {
35
- const dir = path.dirname(this.dataFilePath)
36
- if (!fs.existsSync(dir)) {
37
- fs.mkdirSync(dir, { recursive: true })
39
+ // 清理旧版本的JSON文件
40
+ private cleanupOldDataFiles(baseDir: string) {
41
+ const oldFiles = ['chat-data.json', 'data.json', 'messages.json']
42
+ for (const fileName of oldFiles) {
43
+ 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) {
49
+ this.logger.warn(`删除旧版本数据文件失败: ${fileName}`, error)
50
+ }
51
+ }
38
52
  }
39
53
  }
40
54
 
41
- readChatDataFromFile(): ChatData {
55
+ // 确保目录存在
56
+ private ensureDir(dirPath: string) {
57
+ if (!fs.existsSync(dirPath)) {
58
+ fs.mkdirSync(dirPath, { recursive: true })
59
+ }
60
+ }
61
+
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`)
67
+ }
68
+
69
+ // 读取单个频道的消息
70
+ private readChannelMessages(selfId: string, channelId: string): MessageInfo[] {
71
+ const filePath = this.getChannelFilePath(selfId, channelId)
72
+ if (!fs.existsSync(filePath)) {
73
+ return []
74
+ }
75
+
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 []
83
+ }
84
+ }
85
+
86
+ // 写入单个频道的消息
87
+ private writeChannelMessages(selfId: string, channelId: string, messages: MessageInfo[]) {
88
+ const filePath = this.getChannelFilePath(selfId, channelId)
89
+ 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)
94
+ }
95
+ }
96
+
97
+ // 读取元数据(bots、channels、pinned等)
98
+ private readMetadata(): Omit<ChatData, 'messages'> {
99
+ if (!fs.existsSync(this.metadataFilePath)) {
100
+ return {
101
+ bots: {},
102
+ channels: {},
103
+ pinnedBots: [],
104
+ pinnedChannels: []
105
+ }
106
+ }
107
+
108
+ try {
109
+ const jsonData = fs.readFileSync(this.metadataFilePath, 'utf8')
110
+ const data = JSON.parse(jsonData)
111
+ return {
112
+ bots: data.bots || {},
113
+ channels: data.channels || {},
114
+ pinnedBots: data.pinnedBots || [],
115
+ pinnedChannels: data.pinnedChannels || [],
116
+ lastSaveTime: data.lastSaveTime
117
+ }
118
+ } catch (error) {
119
+ this.logger.error('读取元数据失败:', error)
120
+ return {
121
+ bots: {},
122
+ channels: {},
123
+ pinnedBots: [],
124
+ pinnedChannels: []
125
+ }
126
+ }
127
+ }
128
+
129
+ // 写入元数据
130
+ private writeMetadata(metadata: Omit<ChatData, 'messages'>) {
131
+ try {
132
+ this.ensureDir(path.dirname(this.metadataFilePath))
133
+ const dataToWrite = {
134
+ ...metadata,
135
+ lastSaveTime: Date.now()
136
+ }
137
+ const jsonData = JSON.stringify(dataToWrite, null, 2)
138
+ fs.writeFileSync(this.metadataFilePath, jsonData, 'utf8')
139
+ } catch (error) {
140
+ this.logger.error('写入元数据失败:', error)
141
+ }
142
+ }
143
+
144
+ // 扫描所有频道消息文件并加载到内存
145
+ private loadAllChannelMessages(): Record<string, MessageInfo[]> {
146
+ const messages: Record<string, MessageInfo[]> = {}
147
+
148
+ if (!fs.existsSync(this.chatHistoryDir)) {
149
+ return messages
150
+ }
151
+
152
+ try {
153
+ const botDirs = fs.readdirSync(this.chatHistoryDir)
154
+ for (const botId of botDirs) {
155
+ const botDir = path.join(this.chatHistoryDir, botId)
156
+ const stat = fs.statSync(botDir)
157
+ if (!stat.isDirectory()) continue
42
158
 
159
+ const channelFiles = fs.readdirSync(botDir)
160
+ for (const fileName of channelFiles) {
161
+ if (!fileName.endsWith('.json')) continue
162
+
163
+ const channelId = fileName.replace('.json', '')
164
+ const channelKey = `${botId}:${channelId}`
165
+ messages[channelKey] = this.readChannelMessages(botId, channelId)
166
+ }
167
+ }
168
+ } catch (error) {
169
+ this.logger.error('加载频道消息失败:', error)
170
+ }
171
+
172
+ return messages
173
+ }
174
+
175
+ readChatDataFromFile(): ChatData {
176
+ // 如果已有缓存,直接返回
43
177
  if (this.memoryCache) {
44
178
  return this.memoryCache
45
179
  }
46
180
 
181
+ // 异步加载数据
47
182
  process.nextTick(() => {
48
183
  try {
49
- if (fs.existsSync(this.dataFilePath)) {
50
- const jsonData = fs.readFileSync(this.dataFilePath, 'utf8')
51
- const data = JSON.parse(jsonData)
52
- this.memoryCache = {
53
- bots: data.bots || {},
54
- channels: data.channels || {},
55
- messages: data.messages || {},
56
- pinnedBots: data.pinnedBots || [],
57
- pinnedChannels: data.pinnedChannels || [],
58
- lastSaveTime: data.lastSaveTime
59
- }
184
+ const metadata = this.readMetadata()
185
+ const messages = this.loadAllChannelMessages()
186
+ this.memoryCache = {
187
+ ...metadata,
188
+ messages
60
189
  }
61
190
  } catch (error) {
62
191
  this.logger.error('读取聊天数据失败:', error)
63
192
  }
64
193
  })
65
194
 
195
+ // 先返回空数据
66
196
  this.memoryCache = {
67
197
  bots: {},
68
198
  channels: {},
@@ -74,52 +204,64 @@ export class FileManager {
74
204
  }
75
205
 
76
206
  writeChatDataToFile(data: ChatData) {
77
-
78
207
  data.lastSaveTime = Date.now()
79
208
  this.memoryCache = data
80
209
 
81
210
  process.nextTick(() => {
82
211
  try {
83
- this.ensureDataDir()
84
- const jsonData = JSON.stringify(data, null, 2)
85
- fs.writeFileSync(this.dataFilePath, jsonData, 'utf8')
212
+ // 写入元数据
213
+ const { messages, ...metadata } = data
214
+ this.writeMetadata(metadata)
215
+
216
+ // 写入各个频道的消息
217
+ for (const [channelKey, channelMessages] of Object.entries(messages)) {
218
+ const [selfId, channelId] = channelKey.split(':')
219
+ if (selfId && channelId) {
220
+ this.writeChannelMessages(selfId, channelId, channelMessages)
221
+ }
222
+ }
86
223
  } catch (error) {
87
224
  this.logger.error('写入聊天数据失败:', error)
88
225
  }
89
226
  })
90
227
  }
91
228
 
92
- private scheduleWrite() {
93
-
94
- if (this.writeTimer) {
95
- this.writeTimer()
96
- this.writeTimer = null
229
+ // 为特定频道安排写入
230
+ private scheduleWrite(channelKey: string) {
231
+ // 取消之前的定时器
232
+ const existingTimer = this.writeTimers.get(channelKey)
233
+ if (existingTimer) {
234
+ existingTimer()
97
235
  }
98
236
 
99
- this.writeTimer = this.ctx.setTimeout(() => {
100
-
237
+ // 创建新的定时器
238
+ const timer = this.ctx.setTimeout(() => {
101
239
  process.nextTick(() => {
102
- this.flushPendingMessages()
240
+ this.flushPendingMessages(channelKey)
103
241
  })
104
- this.writeTimer = null
242
+ this.writeTimers.delete(channelKey)
105
243
  }, this.WRITE_DEBOUNCE_MS)
244
+
245
+ this.writeTimers.set(channelKey, timer)
106
246
  }
107
247
 
108
- private flushPendingMessages() {
109
- if (this.pendingMessages.length === 0) return
248
+ // 刷新特定频道的待写入消息
249
+ private flushPendingMessages(channelKey: string) {
250
+ const messagesToWrite = this.pendingMessages.get(channelKey)
251
+ if (!messagesToWrite || messagesToWrite.length === 0) return
110
252
 
111
- const messagesToWrite = [...this.pendingMessages]
112
- this.pendingMessages = []
253
+ this.pendingMessages.delete(channelKey)
113
254
 
114
255
  const data = this.memoryCache || this.readChatDataFromFile()
256
+ const [selfId, channelId] = channelKey.split(':')
115
257
 
116
- for (const messageInfo of messagesToWrite) {
117
- const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
258
+ if (!selfId || !channelId) return
118
259
 
119
- if (!data.messages[channelKey]) {
120
- data.messages[channelKey] = []
121
- }
260
+ if (!data.messages[channelKey]) {
261
+ data.messages[channelKey] = []
262
+ }
122
263
 
264
+ for (const messageInfo of messagesToWrite) {
123
265
  const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
124
266
  if (existingMessage) {
125
267
  continue
@@ -131,15 +273,19 @@ export class FileManager {
131
273
 
132
274
  const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
133
275
  data.messages[channelKey].push(cleanedMessageInfo)
276
+ }
134
277
 
135
- if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
136
- data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
137
- data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
138
- }
278
+ // 限制消息数量
279
+ if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
280
+ data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
281
+ data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
139
282
  }
140
283
 
141
- this.writeChatDataToFile(data)
142
- this.logInfo(`批量写入 ${messagesToWrite.length} 条消息`)
284
+ // 只写入这个频道的消息文件
285
+ this.writeChannelMessages(selfId, channelId, data.messages[channelKey])
286
+ this.memoryCache = data
287
+
288
+ this.logInfo(`批量写入 ${messagesToWrite.length} 条消息到频道 ${channelKey}`)
143
289
  }
144
290
 
145
291
  cleanExcessMessages(data: ChatData): ChatData {
@@ -169,11 +315,16 @@ export class FileManager {
169
315
  }
170
316
 
171
317
  async addMessageToFile(messageInfo: MessageInfo) {
318
+ const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
172
319
 
173
- this.pendingMessages.push(messageInfo)
320
+ // 添加到待写入队列
321
+ if (!this.pendingMessages.has(channelKey)) {
322
+ this.pendingMessages.set(channelKey, [])
323
+ }
324
+ this.pendingMessages.get(channelKey)!.push(messageInfo)
174
325
 
326
+ // 更新内存缓存
175
327
  const data = this.memoryCache || this.readChatDataFromFile()
176
- const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
177
328
 
178
329
  if (!data.messages[channelKey]) {
179
330
  data.messages[channelKey] = []
@@ -195,16 +346,17 @@ export class FileManager {
195
346
  this.memoryCache = data
196
347
  }
197
348
 
198
- this.scheduleWrite()
349
+ // 安排写入
350
+ this.scheduleWrite(channelKey)
199
351
  }
200
352
 
201
353
  dispose() {
202
- if (this.writeTimer) {
203
- this.writeTimer()
204
- this.writeTimer = null
354
+ // 取消所有定时器
355
+ for (const [channelKey, timer] of this.writeTimers.entries()) {
356
+ timer()
357
+ this.flushPendingMessages(channelKey)
205
358
  }
206
-
207
- this.flushPendingMessages()
359
+ this.writeTimers.clear()
208
360
  }
209
361
 
210
362
  private logInfo(...args: any[]) {