koishi-plugin-chat-patch 2.2.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.
@@ -1,169 +1,367 @@
1
- import { ChatData, MessageInfo } from './types'
2
- import { Context, Logger } from 'koishi'
3
- import { Config } from './config'
4
- import { Utils } from './utils'
5
-
6
- import path from 'node:path'
7
- import fs from 'node:fs'
8
-
9
- export class FileManager {
10
- private dataFilePath: string
11
- private fileOperationLock = Promise.resolve()
12
- private logger: Logger
13
- private utils: Utils
14
-
15
- constructor(
16
- private ctx: Context,
17
- private config: Config
18
- ) {
19
- this.dataFilePath = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'chat-data.json')
20
- this.logger = ctx.logger('chat-patch')
21
- this.utils = new Utils(config)
22
- }
23
-
24
- // 确保目录存在
25
- private ensureDataDir() {
26
- const dir = path.dirname(this.dataFilePath)
27
- if (!fs.existsSync(dir)) {
28
- fs.mkdirSync(dir, { recursive: true })
29
- }
30
- }
31
-
32
- // 从JSON文件读取数据
33
- readChatDataFromFile(): ChatData {
34
- try {
35
- if (fs.existsSync(this.dataFilePath)) {
36
- const jsonData = fs.readFileSync(this.dataFilePath, 'utf8')
37
- const data = JSON.parse(jsonData)
38
- return {
39
- bots: data.bots || {},
40
- channels: data.channels || {},
41
- messages: data.messages || {},
42
- pinnedBots: data.pinnedBots || [],
43
- pinnedChannels: data.pinnedChannels || [],
44
- lastSaveTime: data.lastSaveTime
45
- }
46
- }
47
- } catch (error) {
48
- this.logger.error('读取聊天数据失败:', error)
49
- }
50
- return {
51
- bots: {},
52
- channels: {},
53
- messages: {},
54
- pinnedBots: [],
55
- pinnedChannels: []
56
- }
57
- }
58
-
59
- // 写入数据到JSON文件
60
- writeChatDataToFile(data: ChatData) {
61
- try {
62
- this.ensureDataDir()
63
- data.lastSaveTime = Date.now()
64
- const jsonData = JSON.stringify(data, null, 2)
65
- fs.writeFileSync(this.dataFilePath, jsonData, 'utf8')
66
- } catch (error) {
67
- this.logger.error('写入聊天数据失败:', error)
68
- }
69
- }
70
-
71
- // 清理超量消息
72
- cleanExcessMessages(data: ChatData): ChatData {
73
- let cleanedCount = 0
74
- const cleanedMessages: Record<string, MessageInfo[]> = {}
75
-
76
- for (const [channelKey, messages] of Object.entries(data.messages)) {
77
- if (messages.length > this.config.maxMessagesPerChannel) {
78
- const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
79
- const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
80
- cleanedCount += messages.length - keptMessages.length
81
- cleanedMessages[channelKey] = keptMessages
82
- this.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
83
- } else {
84
- cleanedMessages[channelKey] = messages
85
- }
86
- }
87
-
88
- if (cleanedCount > 0) {
89
- this.logInfo('总共清理超量消息:', cleanedCount, '条')
90
- }
91
-
92
- return {
93
- ...data,
94
- messages: cleanedMessages
95
- }
96
- }
97
-
98
- // 添加消息到JSON文件(使用锁机制防止并发冲突)
99
- async addMessageToFile(messageInfo: MessageInfo) {
100
- this.fileOperationLock = this.fileOperationLock.then(async () => {
101
- const data = this.readChatDataFromFile()
102
- const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
103
-
104
- if (!data.messages[channelKey]) {
105
- data.messages[channelKey] = []
106
- }
107
-
108
- // 检查消息是否已存在
109
- const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
110
- if (existingMessage) {
111
- this.logInfo('消息已存在,跳过保存:', {
112
- channelKey: channelKey,
113
- messageId: messageInfo.id,
114
- existingType: existingMessage.type,
115
- existingContent: existingMessage.content,
116
- newType: messageInfo.type,
117
- newContent: messageInfo.content
118
- })
119
- return
120
- }
121
-
122
- if (!messageInfo.timestamp) {
123
- messageInfo.timestamp = Date.now()
124
- }
125
-
126
- // 清理消息中的base64内容
127
- const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
128
-
129
- const beforeCount = data.messages[channelKey].length
130
- data.messages[channelKey].push(cleanedMessageInfo)
131
- const afterCount = data.messages[channelKey].length
132
-
133
- // 限制消息数量 - 保留最新的消息
134
- if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
135
- data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
136
- const removedCount = data.messages[channelKey].length - this.config.maxMessagesPerChannel
137
- data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
138
- this.logInfo(`频道 ${channelKey} 达到消息上限,清理了 ${removedCount} 条旧消息`)
139
- }
140
-
141
- this.writeChatDataToFile(data)
142
-
143
- const isCommandMessage = messageInfo.content?.startsWith('++') || messageInfo.content?.startsWith('.')
144
-
145
- this.logInfo('添加消息到文件:', {
146
- channelKey: channelKey,
147
- messageId: messageInfo.id,
148
- content: messageInfo.content,
149
- type: messageInfo.type,
150
- userId: messageInfo.userId,
151
- username: messageInfo.username,
152
- timestamp: messageInfo.timestamp,
153
- isCommandMessage: isCommandMessage,
154
- 消息数变化: `${beforeCount} -> ${afterCount} -> ${data.messages[channelKey].length}`
155
- })
156
-
157
- }).catch(error => {
158
- this.logger.error('保存消息时发生错误:', error)
159
- })
160
-
161
- await this.fileOperationLock
162
- }
163
-
164
- private logInfo(...args: any[]) {
165
- if (this.config.loggerinfo) {
166
- (this.logger.info as (...args: any[]) => void)(...args)
167
- }
168
- }
169
- }
1
+ import { ChatData, MessageInfo } from './types'
2
+ import { Context, Logger } from 'koishi'
3
+ import { Config } from './config'
4
+ import { Utils } from './utils'
5
+
6
+ import path from 'node:path'
7
+ import fs from 'node:fs'
8
+
9
+ export class FileManager {
10
+ private chatHistoryDir: string // 聊天记录根目录
11
+ private metadataFilePath: string // 元数据文件路径(存储bots、channels、pinned等信息)
12
+ private logger: Logger
13
+ private utils: Utils
14
+
15
+ private memoryCache: ChatData | null = null
16
+
17
+ private pendingMessages: Map<string, MessageInfo[]> = new Map() // 按channelKey分组的待写入消息
18
+
19
+ private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
20
+
21
+ private readonly WRITE_DEBOUNCE_MS = 1000
22
+
23
+ constructor(
24
+ private ctx: Context,
25
+ private config: Config
26
+ ) {
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')
30
+ this.logger = ctx.logger('chat-patch')
31
+ this.utils = new Utils(config)
32
+
33
+ // 清理旧版本的单文件存储
34
+ this.cleanupOldDataFiles(baseDir)
35
+
36
+ this.memoryCache = this.readChatDataFromFile()
37
+ }
38
+
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
+ }
52
+ }
53
+ }
54
+
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
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
+ // 如果已有缓存,直接返回
177
+ if (this.memoryCache) {
178
+ return this.memoryCache
179
+ }
180
+
181
+ // 异步加载数据
182
+ process.nextTick(() => {
183
+ try {
184
+ const metadata = this.readMetadata()
185
+ const messages = this.loadAllChannelMessages()
186
+ this.memoryCache = {
187
+ ...metadata,
188
+ messages
189
+ }
190
+ } catch (error) {
191
+ this.logger.error('读取聊天数据失败:', error)
192
+ }
193
+ })
194
+
195
+ // 先返回空数据
196
+ this.memoryCache = {
197
+ bots: {},
198
+ channels: {},
199
+ messages: {},
200
+ pinnedBots: [],
201
+ pinnedChannels: []
202
+ }
203
+ return this.memoryCache
204
+ }
205
+
206
+ writeChatDataToFile(data: ChatData) {
207
+ data.lastSaveTime = Date.now()
208
+ this.memoryCache = data
209
+
210
+ process.nextTick(() => {
211
+ try {
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
+ }
223
+ } catch (error) {
224
+ this.logger.error('写入聊天数据失败:', error)
225
+ }
226
+ })
227
+ }
228
+
229
+ // 为特定频道安排写入
230
+ private scheduleWrite(channelKey: string) {
231
+ // 取消之前的定时器
232
+ const existingTimer = this.writeTimers.get(channelKey)
233
+ if (existingTimer) {
234
+ existingTimer()
235
+ }
236
+
237
+ // 创建新的定时器
238
+ const timer = this.ctx.setTimeout(() => {
239
+ process.nextTick(() => {
240
+ this.flushPendingMessages(channelKey)
241
+ })
242
+ this.writeTimers.delete(channelKey)
243
+ }, this.WRITE_DEBOUNCE_MS)
244
+
245
+ this.writeTimers.set(channelKey, timer)
246
+ }
247
+
248
+ // 刷新特定频道的待写入消息
249
+ private flushPendingMessages(channelKey: string) {
250
+ const messagesToWrite = this.pendingMessages.get(channelKey)
251
+ if (!messagesToWrite || messagesToWrite.length === 0) return
252
+
253
+ this.pendingMessages.delete(channelKey)
254
+
255
+ const data = this.memoryCache || this.readChatDataFromFile()
256
+ const [selfId, channelId] = channelKey.split(':')
257
+
258
+ if (!selfId || !channelId) return
259
+
260
+ if (!data.messages[channelKey]) {
261
+ data.messages[channelKey] = []
262
+ }
263
+
264
+ for (const messageInfo of messagesToWrite) {
265
+ const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
266
+ if (existingMessage) {
267
+ continue
268
+ }
269
+
270
+ if (!messageInfo.timestamp) {
271
+ messageInfo.timestamp = Date.now()
272
+ }
273
+
274
+ const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
275
+ data.messages[channelKey].push(cleanedMessageInfo)
276
+ }
277
+
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)
282
+ }
283
+
284
+ // 只写入这个频道的消息文件
285
+ this.writeChannelMessages(selfId, channelId, data.messages[channelKey])
286
+ this.memoryCache = data
287
+
288
+ this.logInfo(`批量写入 ${messagesToWrite.length} 条消息到频道 ${channelKey}`)
289
+ }
290
+
291
+ cleanExcessMessages(data: ChatData): ChatData {
292
+ let cleanedCount = 0
293
+ const cleanedMessages: Record<string, MessageInfo[]> = {}
294
+
295
+ for (const [channelKey, messages] of Object.entries(data.messages)) {
296
+ if (messages.length > this.config.maxMessagesPerChannel) {
297
+ const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
298
+ const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
299
+ cleanedCount += messages.length - keptMessages.length
300
+ cleanedMessages[channelKey] = keptMessages
301
+ this.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
302
+ } else {
303
+ cleanedMessages[channelKey] = messages
304
+ }
305
+ }
306
+
307
+ if (cleanedCount > 0) {
308
+ this.logInfo('总共清理超量消息:', cleanedCount, '条')
309
+ }
310
+
311
+ return {
312
+ ...data,
313
+ messages: cleanedMessages
314
+ }
315
+ }
316
+
317
+ async addMessageToFile(messageInfo: MessageInfo) {
318
+ const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
319
+
320
+ // 添加到待写入队列
321
+ if (!this.pendingMessages.has(channelKey)) {
322
+ this.pendingMessages.set(channelKey, [])
323
+ }
324
+ this.pendingMessages.get(channelKey)!.push(messageInfo)
325
+
326
+ // 更新内存缓存
327
+ const data = this.memoryCache || this.readChatDataFromFile()
328
+
329
+ if (!data.messages[channelKey]) {
330
+ data.messages[channelKey] = []
331
+ }
332
+
333
+ const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
334
+ if (!existingMessage) {
335
+ if (!messageInfo.timestamp) {
336
+ messageInfo.timestamp = Date.now()
337
+ }
338
+ const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
339
+ data.messages[channelKey].push(cleanedMessageInfo)
340
+
341
+ if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
342
+ data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
343
+ data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
344
+ }
345
+
346
+ this.memoryCache = data
347
+ }
348
+
349
+ // 安排写入
350
+ this.scheduleWrite(channelKey)
351
+ }
352
+
353
+ dispose() {
354
+ // 取消所有定时器
355
+ for (const [channelKey, timer] of this.writeTimers.entries()) {
356
+ timer()
357
+ this.flushPendingMessages(channelKey)
358
+ }
359
+ this.writeTimers.clear()
360
+ }
361
+
362
+ private logInfo(...args: any[]) {
363
+ if (this.config.loggerinfo) {
364
+ (this.logger.info as (...args: any[]) => void)(...args)
365
+ }
366
+ }
367
+ }