koishi-plugin-chat-patch 2.1.2 → 2.3.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/src/config.ts CHANGED
@@ -1,46 +1,45 @@
1
- import { Schema } from 'koishi'
2
-
3
- export interface Config {
4
- loggerinfo: boolean
5
- clearIndexedDBOnStart: boolean
6
- maxMessagesPerChannel: number
7
- keepMessagesOnClear: number
8
- maxPersistImages: number
9
- blockedPlatforms: Array<{
10
- platformName: string
11
- exactMatch: boolean
12
- }>
13
- }
14
-
15
- export const Config: Schema<Config> = Schema.intersect([
16
- Schema.object({
17
- 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
- maxPersistImages: Schema.number().default(100).description('持久化存储的图片缓存数量').min(10).max(500).step(1),
20
- blockedPlatforms: Schema.array(Schema.object({
21
- platformName: Schema.string().description('平台名称或关键词'),
22
- exactMatch: Schema.boolean().default(false).description('完全匹配?如果关闭,包含关键词即屏蔽').default(true)
23
- })).role('table').description('屏蔽的平台列表').default(
24
- [
25
- {
26
- "platformName": "qq",
27
- "exactMatch": true
28
- },
29
- {
30
- "platformName": "qqguild",
31
- "exactMatch": true
32
- },
33
- {
34
- "platformName": "sandbox",
35
- "exactMatch": false
36
- }
37
- ]
38
- ),
39
- }).description('基础设置'),
40
-
41
-
42
- Schema.object({
43
- clearIndexedDBOnStart: Schema.boolean().default(true).description('启动时强制清空IndexedDB缓存(适用于紧急情况,防止浏览器卡死)'),
44
- loggerinfo: Schema.boolean().default(false).description('日志调试模式').experimental(),
45
- }).description('开发者选项'),
46
- ])
1
+ import { Schema } from 'koishi'
2
+
3
+ export interface Config {
4
+ loggerinfo: boolean
5
+ clearIndexedDBOnStart: boolean
6
+ maxMessagesPerChannel: number
7
+ keepMessagesOnClear: number
8
+ maxPersistImages: number
9
+ blockedPlatforms: Array<{
10
+ platformName: string
11
+ exactMatch: boolean
12
+ }>
13
+ }
14
+
15
+ export const Config: Schema<Config> = Schema.intersect([
16
+ Schema.object({
17
+ 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
+ maxPersistImages: Schema.number().default(100).description('持久化存储的图片缓存数量').min(10).max(500).step(1),
20
+ blockedPlatforms: Schema.array(Schema.object({
21
+ platformName: Schema.string().description('平台名称或关键词'),
22
+ exactMatch: Schema.boolean().default(false).description('完全匹配?如果关闭,包含关键词即屏蔽').default(true)
23
+ })).role('table').description('屏蔽的平台列表').default(
24
+ [
25
+ {
26
+ "platformName": "qq",
27
+ "exactMatch": true
28
+ },
29
+ {
30
+ "platformName": "qqguild",
31
+ "exactMatch": true
32
+ },
33
+ {
34
+ "platformName": "sandbox",
35
+ "exactMatch": false
36
+ }
37
+ ]
38
+ ),
39
+ }).description('基础设置'),
40
+
41
+ Schema.object({
42
+ clearIndexedDBOnStart: Schema.boolean().default(true).description('启动时强制清空IndexedDB缓存(适用于紧急情况,防止浏览器卡死)'),
43
+ loggerinfo: Schema.boolean().default(false).description('日志调试模式').experimental(),
44
+ }).description('开发者选项'),
45
+ ])
@@ -1,169 +1,215 @@
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 dataFilePath: string
11
+ private fileOperationLock = Promise.resolve()
12
+ private logger: Logger
13
+ private utils: Utils
14
+
15
+ private memoryCache: ChatData | null = null
16
+
17
+ private pendingMessages: MessageInfo[] = []
18
+
19
+ private writeTimer: (() => void) | null = null
20
+
21
+ private readonly WRITE_DEBOUNCE_MS = 1000
22
+
23
+ constructor(
24
+ private ctx: Context,
25
+ private config: Config
26
+ ) {
27
+ this.dataFilePath = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'chat-data.json')
28
+ this.logger = ctx.logger('chat-patch')
29
+ this.utils = new Utils(config)
30
+
31
+ this.memoryCache = this.readChatDataFromFile()
32
+ }
33
+
34
+ private ensureDataDir() {
35
+ const dir = path.dirname(this.dataFilePath)
36
+ if (!fs.existsSync(dir)) {
37
+ fs.mkdirSync(dir, { recursive: true })
38
+ }
39
+ }
40
+
41
+ readChatDataFromFile(): ChatData {
42
+
43
+ if (this.memoryCache) {
44
+ return this.memoryCache
45
+ }
46
+
47
+ process.nextTick(() => {
48
+ try {
49
+ if (fs.existsSync(this.dataFilePath)) {
50
+ const jsonData = fs.readFileSync(this.dataFilePath, 'utf8')
51
+ const data = JSON.parse(jsonData)
52
+ this.memoryCache = {
53
+ bots: data.bots || {},
54
+ channels: data.channels || {},
55
+ messages: data.messages || {},
56
+ pinnedBots: data.pinnedBots || [],
57
+ pinnedChannels: data.pinnedChannels || [],
58
+ lastSaveTime: data.lastSaveTime
59
+ }
60
+ }
61
+ } catch (error) {
62
+ this.logger.error('读取聊天数据失败:', error)
63
+ }
64
+ })
65
+
66
+ this.memoryCache = {
67
+ bots: {},
68
+ channels: {},
69
+ messages: {},
70
+ pinnedBots: [],
71
+ pinnedChannels: []
72
+ }
73
+ return this.memoryCache
74
+ }
75
+
76
+ writeChatDataToFile(data: ChatData) {
77
+
78
+ data.lastSaveTime = Date.now()
79
+ this.memoryCache = data
80
+
81
+ process.nextTick(() => {
82
+ try {
83
+ this.ensureDataDir()
84
+ const jsonData = JSON.stringify(data, null, 2)
85
+ fs.writeFileSync(this.dataFilePath, jsonData, 'utf8')
86
+ } catch (error) {
87
+ this.logger.error('写入聊天数据失败:', error)
88
+ }
89
+ })
90
+ }
91
+
92
+ private scheduleWrite() {
93
+
94
+ if (this.writeTimer) {
95
+ this.writeTimer()
96
+ this.writeTimer = null
97
+ }
98
+
99
+ this.writeTimer = this.ctx.setTimeout(() => {
100
+
101
+ process.nextTick(() => {
102
+ this.flushPendingMessages()
103
+ })
104
+ this.writeTimer = null
105
+ }, this.WRITE_DEBOUNCE_MS)
106
+ }
107
+
108
+ private flushPendingMessages() {
109
+ if (this.pendingMessages.length === 0) return
110
+
111
+ const messagesToWrite = [...this.pendingMessages]
112
+ this.pendingMessages = []
113
+
114
+ const data = this.memoryCache || this.readChatDataFromFile()
115
+
116
+ for (const messageInfo of messagesToWrite) {
117
+ const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
118
+
119
+ if (!data.messages[channelKey]) {
120
+ data.messages[channelKey] = []
121
+ }
122
+
123
+ const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
124
+ if (existingMessage) {
125
+ continue
126
+ }
127
+
128
+ if (!messageInfo.timestamp) {
129
+ messageInfo.timestamp = Date.now()
130
+ }
131
+
132
+ const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
133
+ data.messages[channelKey].push(cleanedMessageInfo)
134
+
135
+ if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
136
+ data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
137
+ data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
138
+ }
139
+ }
140
+
141
+ this.writeChatDataToFile(data)
142
+ this.logInfo(`批量写入 ${messagesToWrite.length} 条消息`)
143
+ }
144
+
145
+ cleanExcessMessages(data: ChatData): ChatData {
146
+ let cleanedCount = 0
147
+ const cleanedMessages: Record<string, MessageInfo[]> = {}
148
+
149
+ for (const [channelKey, messages] of Object.entries(data.messages)) {
150
+ if (messages.length > this.config.maxMessagesPerChannel) {
151
+ const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
152
+ const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
153
+ cleanedCount += messages.length - keptMessages.length
154
+ cleanedMessages[channelKey] = keptMessages
155
+ this.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
156
+ } else {
157
+ cleanedMessages[channelKey] = messages
158
+ }
159
+ }
160
+
161
+ if (cleanedCount > 0) {
162
+ this.logInfo('总共清理超量消息:', cleanedCount, '条')
163
+ }
164
+
165
+ return {
166
+ ...data,
167
+ messages: cleanedMessages
168
+ }
169
+ }
170
+
171
+ async addMessageToFile(messageInfo: MessageInfo) {
172
+
173
+ this.pendingMessages.push(messageInfo)
174
+
175
+ const data = this.memoryCache || this.readChatDataFromFile()
176
+ const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
177
+
178
+ if (!data.messages[channelKey]) {
179
+ data.messages[channelKey] = []
180
+ }
181
+
182
+ const existingMessage = data.messages[channelKey].find(m => m.id === messageInfo.id)
183
+ if (!existingMessage) {
184
+ if (!messageInfo.timestamp) {
185
+ messageInfo.timestamp = Date.now()
186
+ }
187
+ const cleanedMessageInfo = this.utils.cleanBase64Content(messageInfo) as MessageInfo
188
+ data.messages[channelKey].push(cleanedMessageInfo)
189
+
190
+ if (data.messages[channelKey].length > this.config.maxMessagesPerChannel) {
191
+ data.messages[channelKey].sort((a, b) => a.timestamp - b.timestamp)
192
+ data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
193
+ }
194
+
195
+ this.memoryCache = data
196
+ }
197
+
198
+ this.scheduleWrite()
199
+ }
200
+
201
+ dispose() {
202
+ if (this.writeTimer) {
203
+ this.writeTimer()
204
+ this.writeTimer = null
205
+ }
206
+
207
+ this.flushPendingMessages()
208
+ }
209
+
210
+ private logInfo(...args: any[]) {
211
+ if (this.config.loggerinfo) {
212
+ (this.logger.info as (...args: any[]) => void)(...args)
213
+ }
214
+ }
215
+ }