koishi-plugin-chat-patch 2.3.0 → 2.4.4
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.
- package/client/vue/composables/useChatData.ts +0 -1
- package/client/vue/composables/useImageCache.ts +162 -21
- package/client/vue/composables/useVideoCache.ts +16 -21
- package/client/vue/types.ts +0 -1
- package/dist/index.js +3 -3
- package/lib/config.d.ts +0 -1
- package/lib/file-manager.d.ts +12 -4
- package/lib/index.js +276 -115
- package/lib/message-handler.d.ts +1 -1
- package/package.json +1 -1
- package/src/api-handlers.ts +103 -65
- package/src/config.ts +0 -2
- package/src/file-manager.ts +214 -57
- package/src/message-handler.ts +12 -7
package/src/api-handlers.ts
CHANGED
|
@@ -35,10 +35,10 @@ export class ApiHandlers {
|
|
|
35
35
|
|
|
36
36
|
this.ctx.console.addListener('get-chat-data' as any, async () => {
|
|
37
37
|
try {
|
|
38
|
+
// 只读取元数据,不加载消息到内存
|
|
39
|
+
const data = this.fileManager.readMetadataOnly()
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this.logInfo('获取基础聊天数据')
|
|
41
|
+
this.logInfo('获取基础聊天数据(仅元数据)')
|
|
42
42
|
|
|
43
43
|
return {
|
|
44
44
|
success: true,
|
|
@@ -47,7 +47,7 @@ export class ApiHandlers {
|
|
|
47
47
|
channels: data.channels || {},
|
|
48
48
|
pinnedBots: data.pinnedBots || [],
|
|
49
49
|
pinnedChannels: data.pinnedChannels || [],
|
|
50
|
-
|
|
50
|
+
// 不返回消息数据,由前端按需加载
|
|
51
51
|
messages: {}
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -64,9 +64,8 @@ export class ApiHandlers {
|
|
|
64
64
|
offset?: number
|
|
65
65
|
}) => {
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
let messages = data.messages[channelKey] || []
|
|
67
|
+
// 直接从文件读取该频道的消息,不加载所有消息到内存
|
|
68
|
+
let messages = this.fileManager.readChannelMessages(requestData.selfId, requestData.channelId)
|
|
70
69
|
|
|
71
70
|
const sortedMessages = messages.sort((a, b) => b.timestamp - a.timestamp)
|
|
72
71
|
|
|
@@ -77,11 +76,11 @@ export class ApiHandlers {
|
|
|
77
76
|
|
|
78
77
|
messages = messages.sort((a, b) => a.timestamp - b.timestamp)
|
|
79
78
|
} else {
|
|
80
|
-
|
|
79
|
+
// 如果没有指定limit,返回所有消息(按时间正序)
|
|
81
80
|
messages = sortedMessages.sort((a, b) => a.timestamp - b.timestamp)
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
this.logInfo('获取历史消息:',
|
|
83
|
+
this.logInfo('获取历史消息:', `${requestData.selfId}:${requestData.channelId}`, '共', messages.length, '条消息')
|
|
85
84
|
|
|
86
85
|
return {
|
|
87
86
|
success: true,
|
|
@@ -120,45 +119,75 @@ export class ApiHandlers {
|
|
|
120
119
|
|
|
121
120
|
this.ctx.console.addListener('fetch-image' as any, async (data: { url: string }) => {
|
|
122
121
|
try {
|
|
122
|
+
// 如果已经是 Vite @fs 路径,直接返回
|
|
123
|
+
if (data.url.includes('/vite/@fs/')) {
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
viteUrl: data.url
|
|
127
|
+
}
|
|
128
|
+
}
|
|
123
129
|
|
|
130
|
+
// 如果是本地文件 URL,转换为 Vite @fs 路径
|
|
124
131
|
if (this.isFileUrl(data.url)) {
|
|
125
132
|
this.logInfo('处理本地文件请求:', data.url)
|
|
126
|
-
|
|
133
|
+
const filePath = require('node:url').fileURLToPath(data.url)
|
|
134
|
+
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
viteUrl: `/vite/@fs/${normalizedPath}`
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 网络图片:下载并缓存到本地,返回 Vite @fs 路径
|
|
142
|
+
const dir = require('node:path').join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-media', 'images')
|
|
143
|
+
if (!require('node:fs').existsSync(dir)) {
|
|
144
|
+
require('node:fs').mkdirSync(dir, { recursive: true })
|
|
127
145
|
}
|
|
128
146
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
const crypto = require('node:crypto')
|
|
148
|
+
const hash = crypto.createHash('md5').update(data.url).digest('hex')
|
|
149
|
+
const ext = require('node:path').extname(new URL(data.url).pathname) || '.jpg'
|
|
150
|
+
const filename = `${hash}${ext}`
|
|
151
|
+
const filePath = require('node:path').join(dir, filename)
|
|
152
|
+
|
|
153
|
+
// 如果文件不存在,下载并保存
|
|
154
|
+
if (!require('node:fs').existsSync(filePath)) {
|
|
155
|
+
const response = await fetch(data.url, {
|
|
156
|
+
headers: {
|
|
157
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
158
|
+
'Referer': ''
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
133
164
|
}
|
|
134
|
-
})
|
|
135
165
|
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
const buffer = await response.arrayBuffer()
|
|
167
|
+
require('node:fs').writeFileSync(filePath, Buffer.from(buffer))
|
|
138
168
|
}
|
|
139
169
|
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
|
170
|
+
// 返回 Vite @fs 路径
|
|
171
|
+
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
143
172
|
return {
|
|
144
173
|
success: true,
|
|
145
|
-
|
|
146
|
-
contentType: contentType,
|
|
147
|
-
dataUrl: `data:${contentType};base64,${base64}`
|
|
174
|
+
viteUrl: `/vite/@fs/${normalizedPath}`
|
|
148
175
|
}
|
|
149
176
|
} catch (error: any) {
|
|
177
|
+
this.logger.error('获取图片失败:', error)
|
|
150
178
|
return { success: false, error: error?.message || String(error) }
|
|
151
179
|
}
|
|
152
180
|
})
|
|
153
181
|
|
|
182
|
+
// 清理历史记录 API 已废弃,现在直接通过右键删除频道数据
|
|
154
183
|
this.ctx.console.addListener('clear-channel-history' as any, async (data: {
|
|
155
184
|
selfId: string
|
|
156
185
|
channelId: string
|
|
157
|
-
keepCount?: number
|
|
158
186
|
}) => {
|
|
159
187
|
try {
|
|
160
|
-
this.logInfo('
|
|
188
|
+
this.logInfo('收到清理历史记录请求(已废弃,建议使用删除频道数据):', data)
|
|
161
189
|
|
|
190
|
+
// 直接删除该频道的所有消息
|
|
162
191
|
const chatData = this.fileManager.readChatDataFromFile()
|
|
163
192
|
const channelKey = `${data.selfId}:${data.channelId}`
|
|
164
193
|
|
|
@@ -166,33 +195,19 @@ export class ApiHandlers {
|
|
|
166
195
|
return { success: true, message: '频道没有历史消息' }
|
|
167
196
|
}
|
|
168
197
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const keepCount = data.keepCount || this.config.keepMessagesOnClear
|
|
173
|
-
|
|
174
|
-
if (keepCount > 0 && originalCount <= keepCount) {
|
|
175
|
-
return { success: true, message: `消息数量(${originalCount})未超过保留数量(${keepCount}),无需清理` }
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
|
|
179
|
-
const keptMessages = keepCount > 0 ? sortedMessages.slice(-keepCount) : []
|
|
180
|
-
const clearedCount = originalCount - keptMessages.length
|
|
181
|
-
|
|
182
|
-
chatData.messages[channelKey] = keptMessages
|
|
198
|
+
const originalCount = chatData.messages[channelKey].length
|
|
199
|
+
delete chatData.messages[channelKey]
|
|
183
200
|
this.fileManager.writeChatDataToFile(chatData)
|
|
184
201
|
|
|
185
|
-
this.logInfo(`频道 ${channelKey}
|
|
186
|
-
|
|
187
|
-
保留消息数: keptMessages.length,
|
|
188
|
-
清理消息数: clearedCount
|
|
202
|
+
this.logInfo(`频道 ${channelKey} 历史记录已清空:`, {
|
|
203
|
+
清理消息数: originalCount
|
|
189
204
|
})
|
|
190
205
|
|
|
191
206
|
return {
|
|
192
207
|
success: true,
|
|
193
|
-
message: `成功清理 ${
|
|
194
|
-
clearedCount:
|
|
195
|
-
keptCount:
|
|
208
|
+
message: `成功清理 ${originalCount} 条历史消息`,
|
|
209
|
+
clearedCount: originalCount,
|
|
210
|
+
keptCount: 0
|
|
196
211
|
}
|
|
197
212
|
} catch (error: any) {
|
|
198
213
|
this.logger.error('清理频道历史记录失败:', error)
|
|
@@ -478,7 +493,6 @@ export class ApiHandlers {
|
|
|
478
493
|
success: true,
|
|
479
494
|
config: {
|
|
480
495
|
maxMessagesPerChannel: this.config.maxMessagesPerChannel,
|
|
481
|
-
keepMessagesOnClear: this.config.keepMessagesOnClear,
|
|
482
496
|
maxPersistImages: this.config.maxPersistImages,
|
|
483
497
|
loggerinfo: this.config.loggerinfo,
|
|
484
498
|
blockedPlatforms: this.config.blockedPlatforms || [],
|
|
@@ -579,35 +593,59 @@ export class ApiHandlers {
|
|
|
579
593
|
try {
|
|
580
594
|
this.logInfo('收到视频临时加载请求:', data.url)
|
|
581
595
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
return
|
|
596
|
+
// 如果已经是 Vite @fs 路径,直接返回
|
|
597
|
+
if (data.url.includes('/vite/@fs/')) {
|
|
598
|
+
return {
|
|
599
|
+
success: true,
|
|
600
|
+
viteUrl: data.url
|
|
601
|
+
}
|
|
585
602
|
}
|
|
586
603
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
604
|
+
// 如果是本地文件 URL,转换为 Vite @fs 路径
|
|
605
|
+
if (this.isFileUrl(data.url)) {
|
|
606
|
+
const filePath = require('node:url').fileURLToPath(data.url)
|
|
607
|
+
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
608
|
+
return {
|
|
609
|
+
success: true,
|
|
610
|
+
viteUrl: `/vite/@fs/${normalizedPath}`
|
|
591
611
|
}
|
|
592
|
-
}
|
|
612
|
+
}
|
|
593
613
|
|
|
594
|
-
|
|
595
|
-
|
|
614
|
+
// 网络视频:下载并缓存到本地,返回 Vite @fs 路径
|
|
615
|
+
const dir = require('node:path').join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-media', 'media')
|
|
616
|
+
if (!require('node:fs').existsSync(dir)) {
|
|
617
|
+
require('node:fs').mkdirSync(dir, { recursive: true })
|
|
596
618
|
}
|
|
597
619
|
|
|
598
|
-
const
|
|
599
|
-
const
|
|
620
|
+
const crypto = require('node:crypto')
|
|
621
|
+
const hash = crypto.createHash('md5').update(data.url).digest('hex')
|
|
622
|
+
const ext = require('node:path').extname(new URL(data.url).pathname) || '.mp4'
|
|
623
|
+
const filename = `${hash}${ext}`
|
|
624
|
+
const filePath = require('node:path').join(dir, filename)
|
|
600
625
|
|
|
601
|
-
|
|
626
|
+
// 如果文件不存在,下载并保存
|
|
627
|
+
if (!require('node:fs').existsSync(filePath)) {
|
|
628
|
+
const response = await fetch(data.url, {
|
|
629
|
+
headers: {
|
|
630
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
631
|
+
'Referer': ''
|
|
632
|
+
}
|
|
633
|
+
})
|
|
602
634
|
|
|
603
|
-
|
|
635
|
+
if (!response.ok) {
|
|
636
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const buffer = await response.arrayBuffer()
|
|
640
|
+
require('node:fs').writeFileSync(filePath, Buffer.from(buffer))
|
|
641
|
+
this.logInfo('视频下载成功:', { size: buffer.byteLength, path: filePath })
|
|
642
|
+
}
|
|
604
643
|
|
|
644
|
+
// 返回 Vite @fs 路径
|
|
645
|
+
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
605
646
|
return {
|
|
606
647
|
success: true,
|
|
607
|
-
|
|
608
|
-
contentType: contentType,
|
|
609
|
-
dataUrl: `data:${contentType};base64,${base64}`,
|
|
610
|
-
size: buffer.byteLength
|
|
648
|
+
viteUrl: `/vite/@fs/${normalizedPath}`
|
|
611
649
|
}
|
|
612
650
|
} catch (error: any) {
|
|
613
651
|
this.logger.error('视频临时加载失败:', error)
|
package/src/config.ts
CHANGED
|
@@ -4,7 +4,6 @@ export interface Config {
|
|
|
4
4
|
loggerinfo: boolean
|
|
5
5
|
clearIndexedDBOnStart: boolean
|
|
6
6
|
maxMessagesPerChannel: number
|
|
7
|
-
keepMessagesOnClear: number
|
|
8
7
|
maxPersistImages: number
|
|
9
8
|
blockedPlatforms: Array<{
|
|
10
9
|
platformName: string
|
|
@@ -15,7 +14,6 @@ export interface Config {
|
|
|
15
14
|
export const Config: Schema<Config> = Schema.intersect([
|
|
16
15
|
Schema.object({
|
|
17
16
|
maxMessagesPerChannel: Schema.number().default(500).description('每个群组最大保存消息数量').min(50).max(1500).step(1),
|
|
18
|
-
keepMessagesOnClear: Schema.number().default(50).description('手动清理历史记录时保留的消息数量').min(0).max(1000).step(1),
|
|
19
17
|
maxPersistImages: Schema.number().default(100).description('持久化存储的图片缓存数量').min(10).max(500).step(1),
|
|
20
18
|
blockedPlatforms: Schema.array(Schema.object({
|
|
21
19
|
platformName: Schema.string().description('平台名称或关键词'),
|
package/src/file-manager.ts
CHANGED
|
@@ -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
|
|
11
|
-
private
|
|
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
|
|
19
|
+
private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
|
|
20
20
|
|
|
21
21
|
private readonly WRITE_DEBOUNCE_MS = 1000
|
|
22
22
|
|
|
@@ -24,45 +24,180 @@ export class FileManager {
|
|
|
24
24
|
private ctx: Context,
|
|
25
25
|
private config: Config
|
|
26
26
|
) {
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
+
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
|
+
readMetadataOnly(): Omit<ChatData, 'messages'> {
|
|
131
|
+
return this.readMetadata()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 写入元数据
|
|
135
|
+
private writeMetadata(metadata: Omit<ChatData, 'messages'>) {
|
|
136
|
+
try {
|
|
137
|
+
this.ensureDir(path.dirname(this.metadataFilePath))
|
|
138
|
+
const dataToWrite = {
|
|
139
|
+
...metadata,
|
|
140
|
+
lastSaveTime: Date.now()
|
|
141
|
+
}
|
|
142
|
+
const jsonData = JSON.stringify(dataToWrite, null, 2)
|
|
143
|
+
fs.writeFileSync(this.metadataFilePath, jsonData, 'utf8')
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.logger.error('写入元数据失败:', error)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 扫描所有频道消息文件并加载到内存
|
|
150
|
+
private loadAllChannelMessages(): Record<string, MessageInfo[]> {
|
|
151
|
+
const messages: Record<string, MessageInfo[]> = {}
|
|
152
|
+
|
|
153
|
+
if (!fs.existsSync(this.chatHistoryDir)) {
|
|
154
|
+
return messages
|
|
155
|
+
}
|
|
42
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
|
+
// 如果已有缓存,直接返回
|
|
43
182
|
if (this.memoryCache) {
|
|
44
183
|
return this.memoryCache
|
|
45
184
|
}
|
|
46
185
|
|
|
186
|
+
// 异步加载数据
|
|
47
187
|
process.nextTick(() => {
|
|
48
188
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
channels: data.channels || {},
|
|
55
|
-
messages: data.messages || {},
|
|
56
|
-
pinnedBots: data.pinnedBots || [],
|
|
57
|
-
pinnedChannels: data.pinnedChannels || [],
|
|
58
|
-
lastSaveTime: data.lastSaveTime
|
|
59
|
-
}
|
|
189
|
+
const metadata = this.readMetadata()
|
|
190
|
+
const messages = this.loadAllChannelMessages()
|
|
191
|
+
this.memoryCache = {
|
|
192
|
+
...metadata,
|
|
193
|
+
messages
|
|
60
194
|
}
|
|
61
195
|
} catch (error) {
|
|
62
196
|
this.logger.error('读取聊天数据失败:', error)
|
|
63
197
|
}
|
|
64
198
|
})
|
|
65
199
|
|
|
200
|
+
// 先返回空数据
|
|
66
201
|
this.memoryCache = {
|
|
67
202
|
bots: {},
|
|
68
203
|
channels: {},
|
|
@@ -74,52 +209,64 @@ export class FileManager {
|
|
|
74
209
|
}
|
|
75
210
|
|
|
76
211
|
writeChatDataToFile(data: ChatData) {
|
|
77
|
-
|
|
78
212
|
data.lastSaveTime = Date.now()
|
|
79
213
|
this.memoryCache = data
|
|
80
214
|
|
|
81
215
|
process.nextTick(() => {
|
|
82
216
|
try {
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
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
|
+
}
|
|
86
228
|
} catch (error) {
|
|
87
229
|
this.logger.error('写入聊天数据失败:', error)
|
|
88
230
|
}
|
|
89
231
|
})
|
|
90
232
|
}
|
|
91
233
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
234
|
+
// 为特定频道安排写入
|
|
235
|
+
private scheduleWrite(channelKey: string) {
|
|
236
|
+
// 取消之前的定时器
|
|
237
|
+
const existingTimer = this.writeTimers.get(channelKey)
|
|
238
|
+
if (existingTimer) {
|
|
239
|
+
existingTimer()
|
|
97
240
|
}
|
|
98
241
|
|
|
99
|
-
|
|
100
|
-
|
|
242
|
+
// 创建新的定时器
|
|
243
|
+
const timer = this.ctx.setTimeout(() => {
|
|
101
244
|
process.nextTick(() => {
|
|
102
|
-
this.flushPendingMessages()
|
|
245
|
+
this.flushPendingMessages(channelKey)
|
|
103
246
|
})
|
|
104
|
-
this.
|
|
247
|
+
this.writeTimers.delete(channelKey)
|
|
105
248
|
}, this.WRITE_DEBOUNCE_MS)
|
|
249
|
+
|
|
250
|
+
this.writeTimers.set(channelKey, timer)
|
|
106
251
|
}
|
|
107
252
|
|
|
108
|
-
|
|
109
|
-
|
|
253
|
+
// 刷新特定频道的待写入消息
|
|
254
|
+
private flushPendingMessages(channelKey: string) {
|
|
255
|
+
const messagesToWrite = this.pendingMessages.get(channelKey)
|
|
256
|
+
if (!messagesToWrite || messagesToWrite.length === 0) return
|
|
110
257
|
|
|
111
|
-
|
|
112
|
-
this.pendingMessages = []
|
|
258
|
+
this.pendingMessages.delete(channelKey)
|
|
113
259
|
|
|
114
260
|
const data = this.memoryCache || this.readChatDataFromFile()
|
|
261
|
+
const [selfId, channelId] = channelKey.split(':')
|
|
115
262
|
|
|
116
|
-
|
|
117
|
-
const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
|
|
263
|
+
if (!selfId || !channelId) return
|
|
118
264
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
265
|
+
if (!data.messages[channelKey]) {
|
|
266
|
+
data.messages[channelKey] = []
|
|
267
|
+
}
|
|
122
268
|
|
|
269
|
+
for (const messageInfo of messagesToWrite) {
|
|
123
270
|
const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
|
|
124
271
|
if (existingMessage) {
|
|
125
272
|
continue
|
|
@@ -131,15 +278,19 @@ export class FileManager {
|
|
|
131
278
|
|
|
132
279
|
const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
|
|
133
280
|
data.messages[channelKey].push(cleanedMessageInfo)
|
|
281
|
+
}
|
|
134
282
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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)
|
|
139
287
|
}
|
|
140
288
|
|
|
141
|
-
|
|
142
|
-
this.
|
|
289
|
+
// 只写入这个频道的消息文件
|
|
290
|
+
this.writeChannelMessages(selfId, channelId, data.messages[channelKey])
|
|
291
|
+
this.memoryCache = data
|
|
292
|
+
|
|
293
|
+
this.logInfo(`批量写入 ${messagesToWrite.length} 条消息到频道 ${channelKey}`)
|
|
143
294
|
}
|
|
144
295
|
|
|
145
296
|
cleanExcessMessages(data: ChatData): ChatData {
|
|
@@ -169,11 +320,16 @@ export class FileManager {
|
|
|
169
320
|
}
|
|
170
321
|
|
|
171
322
|
async addMessageToFile(messageInfo: MessageInfo) {
|
|
323
|
+
const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
|
|
172
324
|
|
|
173
|
-
|
|
325
|
+
// 添加到待写入队列
|
|
326
|
+
if (!this.pendingMessages.has(channelKey)) {
|
|
327
|
+
this.pendingMessages.set(channelKey, [])
|
|
328
|
+
}
|
|
329
|
+
this.pendingMessages.get(channelKey)!.push(messageInfo)
|
|
174
330
|
|
|
331
|
+
// 更新内存缓存
|
|
175
332
|
const data = this.memoryCache || this.readChatDataFromFile()
|
|
176
|
-
const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
|
|
177
333
|
|
|
178
334
|
if (!data.messages[channelKey]) {
|
|
179
335
|
data.messages[channelKey] = []
|
|
@@ -195,16 +351,17 @@ export class FileManager {
|
|
|
195
351
|
this.memoryCache = data
|
|
196
352
|
}
|
|
197
353
|
|
|
198
|
-
|
|
354
|
+
// 安排写入
|
|
355
|
+
this.scheduleWrite(channelKey)
|
|
199
356
|
}
|
|
200
357
|
|
|
201
358
|
dispose() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
359
|
+
// 取消所有定时器
|
|
360
|
+
for (const [channelKey, timer] of this.writeTimers.entries()) {
|
|
361
|
+
timer()
|
|
362
|
+
this.flushPendingMessages(channelKey)
|
|
205
363
|
}
|
|
206
|
-
|
|
207
|
-
this.flushPendingMessages()
|
|
364
|
+
this.writeTimers.clear()
|
|
208
365
|
}
|
|
209
366
|
|
|
210
367
|
private logInfo(...args: any[]) {
|