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.
- package/client/icons/activity.vue +9 -9
- package/client/icons/index.ts +4 -4
- package/client/index.scss +90 -90
- package/client/index.ts +25 -25
- package/client/vue/composables/useChatActions.ts +1 -1
- package/client/vue/composables/useChatData.ts +1 -1
- package/client/vue/composables/useImageCache.ts +1 -1
- package/client/vue/types.ts +1 -1
- package/lib/file-manager.d.ts +25 -0
- package/lib/index.js +284 -193
- package/lib/message-handler.d.ts +7 -5
- package/package.json +1 -1
- package/readme.md +25 -25
- package/src/api-handlers.ts +19 -65
- package/src/config.ts +45 -46
- package/src/file-manager.ts +215 -169
- package/src/index.ts +58 -31
- package/src/message-handler.ts +161 -134
- package/src/types.ts +62 -62
- package/src/utils.ts +4 -16
package/src/file-manager.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
data.messages[channelKey] = data.messages[channelKey].slice(-this.config.maxMessagesPerChannel)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
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
|
}
|