koishi-plugin-chat-patch 2.4.5 → 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.
- package/dist/index.js +11 -3
- package/lib/api-handlers.d.ts +6 -1
- package/lib/config.d.ts +2 -0
- package/lib/file-manager.d.ts +102 -13
- package/lib/index.d.ts +17 -0
- package/lib/index.js +1364 -616
- package/lib/logger.d.ts +9 -0
- package/lib/message-handler.d.ts +14 -2
- package/lib/utils.d.ts +8 -3
- package/package.json +1 -1
- package/src/api-handlers.ts +115 -211
- package/src/config.ts +4 -0
- package/src/file-manager.ts +1236 -204
- package/src/index.ts +31 -105
- package/src/logger.ts +29 -0
- package/src/message-handler.ts +216 -154
- package/src/utils.ts +73 -35
package/src/file-manager.ts
CHANGED
|
@@ -1,112 +1,272 @@
|
|
|
1
|
-
import { ChatData, MessageInfo } from './types'
|
|
2
|
-
import { Context
|
|
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
|
|
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.
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
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.
|
|
113
|
+
async upsertChannelInfo(selfId: string, channelId: string, channelInfo: ChannelInfo) {
|
|
114
|
+
await this.ensureMetadataLoaded()
|
|
35
115
|
|
|
36
|
-
this.memoryCache
|
|
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
|
-
//
|
|
40
|
-
private
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
88
|
-
|
|
191
|
+
const loadPromise = this.loadChannelMessages(selfId, channelId)
|
|
192
|
+
this.channelLoadPromises.set(channelKey, loadPromise)
|
|
193
|
+
|
|
89
194
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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.
|
|
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
|
|
337
|
+
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
261
338
|
const [selfId, channelId] = channelKey.split(':')
|
|
262
339
|
|
|
263
340
|
if (!selfId || !channelId) return
|
|
264
341
|
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
335
|
-
|
|
401
|
+
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
402
|
+
if (cachedMessages?.some((message) => message.id === cleanedMessageInfo.id)) {
|
|
403
|
+
return
|
|
336
404
|
}
|
|
337
405
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
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
|
}
|