koishi-plugin-chat-patch 2.2.0 → 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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -38,7 +38,6 @@ export { Config } from './config'
38
38
  export async function apply(ctx: Context, config: Config) {
39
39
  const logger = ctx.logger('chat-patch')
40
40
 
41
- // 启动时清空 media 文件夹(为旧版本做清理工作)
42
41
  const mediaDir = path.join(ctx.baseDir, 'data', 'chat-patch', 'persist-media', 'media')
43
42
  if (require('node:fs').existsSync(mediaDir)) {
44
43
  try {
@@ -61,17 +60,14 @@ export async function apply(ctx: Context, config: Config) {
61
60
  }
62
61
  }
63
62
 
64
- // 初始化各个模块
65
63
  const fileManager = new FileManager(ctx, config)
66
64
  const messageHandler = new MessageHandler(ctx, config, fileManager)
67
65
  const apiHandlers = new ApiHandlers(ctx, config, fileManager, messageHandler)
68
66
  const utils = new Utils(config, ctx)
69
67
 
70
- // 初始化数据
71
68
  const initialData = fileManager.readChatDataFromFile()
72
69
  const cleanedData = fileManager.cleanExcessMessages(initialData)
73
70
 
74
- // 如果清理了数据,立即写回文件
75
71
  const originalCount = Object.values(initialData.messages).reduce((total, msgs) => total + msgs.length, 0)
76
72
  const cleanedCount = Object.values(cleanedData.messages).reduce((total, msgs) => total + msgs.length, 0)
77
73
 
@@ -79,7 +75,6 @@ export async function apply(ctx: Context, config: Config) {
79
75
  fileManager.writeChatDataToFile(cleanedData)
80
76
  }
81
77
 
82
- // 日志调试函数
83
78
  function logInfo(...args: any[]) {
84
79
  if (config.loggerinfo) {
85
80
  (logger.info as (...args: any[]) => void)(...args)
@@ -94,39 +89,67 @@ export async function apply(ctx: Context, config: Config) {
94
89
  总消息数: Object.values(cleanedData.messages).reduce((total, msgs) => total + msgs.length, 0)
95
90
  })
96
91
 
97
- // 监听用户消息
98
- ctx.on('message', async (session) => {
99
- // 检查平台是否被屏蔽
100
- if (utils.isPlatformBlocked(session.platform || 'unknown')) {
101
- logInfo(`忽略来自被屏蔽平台的消息: ${session.platform}`)
102
- return
103
- }
104
- // logInfo(session)
105
- // 广播消息事件给前端处理
106
- await messageHandler.broadcastMessageEvent(session)
92
+ ctx.on('message', (session) => {
93
+ if (utils.isPlatformBlocked(session.platform || 'unknown')) return
94
+
95
+ const timestamp = Date.now()
96
+
97
+ ctx.console.broadcast('chat-message-event', {
98
+ type: 'message',
99
+ selfId: session.selfId,
100
+ platform: session.platform || 'unknown',
101
+ channelId: session.channelId,
102
+ messageId: session.event?.message?.id || `msg-${timestamp}`,
103
+ content: session.content || '',
104
+ userId: session.userId || 'unknown',
105
+ username: session.username || session.userId || 'unknown',
106
+ avatar: session.event?.user?.avatar,
107
+ timestamp: timestamp,
108
+ isDirect: session.isDirect,
109
+ elements: utils.cleanBase64Content(session.elements, false),
110
+ bot: {
111
+ avatar: session.bot.user?.avatar,
112
+ name: session.bot.user?.name,
113
+ }
114
+ })
115
+
116
+ messageHandler.recordUserMessage(session, timestamp)
107
117
  })
108
118
 
109
- // 监听机器人发送的消息
110
- ctx.on('before-send', async (session) => {
111
- // 检查平台是否被屏蔽
112
- if (utils.isPlatformBlocked(session.platform || 'unknown')) {
113
- logInfo(`忽略来自被屏蔽平台的机器人消息: ${session.platform}`)
114
- return
115
- }
116
- // 广播机器人消息事件给前端处理
117
- await messageHandler.broadcastBotMessageEvent(session)
119
+ ctx.on('before-send', (session) => {
120
+ if (utils.isPlatformBlocked(session.platform || 'unknown')) return
121
+
122
+ const timestamp = Date.now()
123
+
124
+ ctx.console.broadcast('chat-bot-message-event', {
125
+ type: 'bot-message',
126
+ selfId: session.selfId,
127
+ platform: session.platform || 'unknown',
128
+ channelId: session.channelId,
129
+ messageId: `bot-msg-${timestamp}`,
130
+ content: session.content || '',
131
+ userId: session.selfId,
132
+ username: session.bot.user?.name || `Bot-${session.selfId}`,
133
+ avatar: session.bot.user?.avatar,
134
+ timestamp: timestamp,
135
+ sending: true,
136
+ elements: utils.cleanBase64Content(session.event?.message?.elements, true),
137
+ bot: {
138
+ avatar: session.bot.user?.avatar,
139
+ name: session.bot.user?.name,
140
+ }
141
+ })
142
+
143
+ messageHandler.recordBotMessage(session, timestamp)
118
144
  })
119
145
 
120
- // 插件启动时设置定期清理过期消息
121
146
  ctx.on('ready', async () => {
122
147
  logInfo('插件启动完成,开始监听消息')
123
148
 
124
- // 定期清理超量消息
125
- setInterval(() => {
149
+ ctx.setInterval(() => {
126
150
  const data = fileManager.readChatDataFromFile()
127
151
  const cleanedData = fileManager.cleanExcessMessages(data)
128
152
 
129
- // 如果有消息被清理,写回文件
130
153
  const originalCount = Object.values(data.messages).reduce((total, msgs) => total + msgs.length, 0)
131
154
  const cleanedCount = Object.values(cleanedData.messages).reduce((total, msgs) => total + msgs.length, 0)
132
155
 
@@ -134,15 +157,19 @@ export async function apply(ctx: Context, config: Config) {
134
157
  fileManager.writeChatDataToFile(cleanedData)
135
158
  logInfo('定期清理完成,清理了', originalCount - cleanedCount, '条超量消息')
136
159
  }
137
- }, 300000) // 每5分钟清理一次
160
+ }, 300000)
138
161
  })
139
162
 
140
- // 注册所有 API 处理器
141
163
  apiHandlers.registerApiHandlers()
142
164
 
143
- // 注册控制台页面
144
165
  ctx.console.addEntry({
145
166
  dev: path.resolve(__dirname, '../client/index.ts'),
146
167
  prod: path.resolve(__dirname, '../dist'),
147
168
  })
169
+
170
+ ctx.on('dispose', () => {
171
+
172
+ fileManager.dispose()
173
+ logInfo('插件已卸载,所有待处理的消息已写入')
174
+ })
148
175
  }