koishi-plugin-chat-patch 2.4.5 → 3.0.1
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/lib/api-handlers.d.ts +6 -1
- package/lib/config.d.ts +2 -0
- package/lib/file-manager.d.ts +104 -13
- package/lib/index.d.ts +17 -0
- package/lib/index.js +1436 -663
- 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 +1268 -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/dist/index.js +0 -5
- package/dist/style.css +0 -1
package/src/message-handler.ts
CHANGED
|
@@ -1,46 +1,51 @@
|
|
|
1
1
|
import { BotInfo, ChannelInfo, MessageInfo, QuoteInfo } from './types'
|
|
2
|
-
import { Context, Session, h
|
|
2
|
+
import { Context, Session, h } from 'koishi'
|
|
3
3
|
import { FileManager } from './file-manager'
|
|
4
4
|
import { Config } from './config'
|
|
5
5
|
import { Utils } from './utils'
|
|
6
|
+
import { PluginLogger } from './logger'
|
|
6
7
|
import { } from '@koishijs/plugin-console'
|
|
8
|
+
import { promises as fs } from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
7
11
|
|
|
8
12
|
export class MessageHandler {
|
|
9
|
-
private logger: Logger
|
|
10
13
|
private utils: Utils
|
|
11
14
|
|
|
12
15
|
private correctChannelIds: Map<string, string> = new Map()
|
|
13
16
|
|
|
17
|
+
private scheduledTasks: Set<() => void> = new Set()
|
|
18
|
+
|
|
19
|
+
private channelRefreshInFlight: Set<string> = new Set()
|
|
20
|
+
|
|
21
|
+
private lastChannelRefreshAt: Map<string, number> = new Map()
|
|
22
|
+
|
|
23
|
+
private readonly CHANNEL_REFRESH_TTL_MS = 10 * 60 * 1000
|
|
24
|
+
|
|
14
25
|
constructor(
|
|
15
26
|
private ctx: Context,
|
|
16
27
|
private config: Config,
|
|
17
|
-
private fileManager: FileManager
|
|
28
|
+
private fileManager: FileManager,
|
|
29
|
+
private logger: PluginLogger
|
|
18
30
|
) {
|
|
19
|
-
this.logger = ctx.logger('chat-patch')
|
|
20
31
|
this.utils = new Utils(config)
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
recordUserMessage(session: Session, timestamp: number) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.processUserMessage(session, timestamp).catch(error => {
|
|
27
|
-
this.logger.error('记录用户消息失败:', error)
|
|
28
|
-
})
|
|
35
|
+
this.scheduleTask('记录用户消息', async () => {
|
|
36
|
+
await this.processUserMessage(session, timestamp)
|
|
29
37
|
})
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
recordBotMessage(session: Session, timestamp: number) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.processBotMessage(session, timestamp).catch(error => {
|
|
36
|
-
this.logger.error('记录机器人消息失败:', error)
|
|
37
|
-
})
|
|
41
|
+
this.scheduleTask('记录机器人消息', async () => {
|
|
42
|
+
await this.processBotMessage(session, timestamp)
|
|
38
43
|
})
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
setCorrectChannelId(selfId: string, channelId: string) {
|
|
42
47
|
this.correctChannelIds.set(selfId, channelId)
|
|
43
|
-
this.logInfo('设置正确的 channelId:', { selfId, channelId })
|
|
48
|
+
this.logger.logInfo('设置正确的 channelId:', { selfId, channelId })
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
getCorrectChannelId(selfId: string): string | undefined {
|
|
@@ -48,25 +53,17 @@ export class MessageHandler {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
updateBotInfoToFile(session: Session) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
platform: session.platform || 'unknown',
|
|
59
|
-
username: session.bot.user?.name || `Bot-${session.selfId}`,
|
|
60
|
-
avatar: session.bot.user?.avatar,
|
|
61
|
-
status: 'online'
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
data.bots[session.selfId] = botInfo
|
|
65
|
-
this.fileManager.writeChatDataToFile(data)
|
|
66
|
-
this.logInfo('更新机器人信息到文件:', botInfo.username)
|
|
67
|
-
} catch (error) {
|
|
68
|
-
this.logger.error('更新机器人信息失败:', error)
|
|
56
|
+
this.scheduleTask('更新机器人信息', async () => {
|
|
57
|
+
const botInfo: BotInfo = {
|
|
58
|
+
selfId: session.selfId,
|
|
59
|
+
platform: session.platform || 'unknown',
|
|
60
|
+
username: session.bot.user?.name || `Bot-${session.selfId}`,
|
|
61
|
+
avatar: session.bot.user?.avatar,
|
|
62
|
+
status: 'online'
|
|
69
63
|
}
|
|
64
|
+
|
|
65
|
+
await this.fileManager.upsertBotInfo(botInfo)
|
|
66
|
+
this.logger.logInfo('更新机器人信息到文件:', botInfo.username)
|
|
70
67
|
})
|
|
71
68
|
}
|
|
72
69
|
|
|
@@ -74,8 +71,7 @@ export class MessageHandler {
|
|
|
74
71
|
const isDirect = session.isDirect || session.channelId?.includes('private')
|
|
75
72
|
const directUserName = session.username || session.event?.user?.name || session.userId
|
|
76
73
|
|
|
77
|
-
const
|
|
78
|
-
const existingChannel = data.channels[session.selfId]?.[session.channelId]
|
|
74
|
+
const existingChannel = this.fileManager.getCachedChannelInfo(session.selfId, session.channelId)
|
|
79
75
|
|
|
80
76
|
let immediateName = session.channelId
|
|
81
77
|
if (isDirect) {
|
|
@@ -92,76 +88,12 @@ export class MessageHandler {
|
|
|
92
88
|
immediateName = existingChannel.guildName
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (session.guildId && session.bot.getGuild && typeof session.bot.getGuild === 'function') {
|
|
103
|
-
const guild = await session.bot.getGuild(session.guildId)
|
|
104
|
-
guildName = guild?.name || session.channelId
|
|
105
|
-
} else if (session.guildId && !session.bot.getGuild && session.bot.getChannel && typeof session.bot.getChannel === 'function') {
|
|
106
|
-
try {
|
|
107
|
-
const channel = await session.bot.getChannel(session.guildId)
|
|
108
|
-
guildName = channel?.name || session.channelId
|
|
109
|
-
} catch (channelError) {
|
|
110
|
-
this.logInfo('获取频道信息失败,使用频道ID作为备用:', channelError)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} catch (error) {
|
|
114
|
-
this.logInfo('获取频道信息失败,使用频道ID作为备用:', error)
|
|
115
|
-
guildName = session.channelId
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const freshData = this.fileManager.readChatDataFromFile()
|
|
120
|
-
if (!freshData.channels[session.selfId]) {
|
|
121
|
-
freshData.channels[session.selfId] = {}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const existingChannel = freshData.channels[session.selfId][session.channelId]
|
|
125
|
-
|
|
126
|
-
let finalName: string
|
|
127
|
-
if (isDirect) {
|
|
128
|
-
if (directUserName && directUserName !== session.userId) {
|
|
129
|
-
finalName = `私聊(${directUserName})`
|
|
130
|
-
} else if (existingChannel?.name && !existingChannel.name.includes('未知')) {
|
|
131
|
-
finalName = existingChannel.name
|
|
132
|
-
} else if (session.platform && session.platform.toLowerCase().includes('sandbox')) {
|
|
133
|
-
finalName = `私聊(${session.userId})`
|
|
134
|
-
} else {
|
|
135
|
-
finalName = '私聊(未知用户)'
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
finalName = guildName || session.channelId
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const channelInfo: ChannelInfo = {
|
|
142
|
-
id: session.channelId,
|
|
143
|
-
name: finalName,
|
|
144
|
-
type: session.type || 0,
|
|
145
|
-
channelId: session.channelId,
|
|
146
|
-
guildName: guildName,
|
|
147
|
-
isDirect: !!isDirect
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (existingChannel && existingChannel.name !== finalName) {
|
|
151
|
-
this.logInfo('更新频道名称:', {
|
|
152
|
-
channelId: session.channelId,
|
|
153
|
-
oldName: existingChannel.name,
|
|
154
|
-
newName: finalName
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
freshData.channels[session.selfId][session.channelId] = channelInfo
|
|
159
|
-
this.fileManager.writeChatDataToFile(freshData)
|
|
160
|
-
this.logInfo('更新频道信息到文件:', channelInfo.name)
|
|
161
|
-
} catch (error) {
|
|
162
|
-
this.logger.error('异步更新频道信息失败:', error)
|
|
163
|
-
}
|
|
164
|
-
})
|
|
91
|
+
const channelKey = `${session.selfId}:${session.channelId}`
|
|
92
|
+
if (this.shouldRefreshChannelInfo(channelKey, existingChannel, isDirect, session.channelId, directUserName)) {
|
|
93
|
+
this.scheduleTask('更新频道信息', async () => {
|
|
94
|
+
await this.refreshChannelInfo(session, existingChannel, isDirect, directUserName)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
165
97
|
|
|
166
98
|
return immediateName
|
|
167
99
|
}
|
|
@@ -177,20 +109,17 @@ export class MessageHandler {
|
|
|
177
109
|
if (type === 'image') folder = 'images'
|
|
178
110
|
else if (type === 'avatar') folder = 'avatars'
|
|
179
111
|
|
|
180
|
-
const dir =
|
|
181
|
-
|
|
182
|
-
require('node:fs').mkdirSync(dir, { recursive: true })
|
|
183
|
-
}
|
|
112
|
+
const dir = path.join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-media', folder)
|
|
113
|
+
await fs.mkdir(dir, { recursive: true })
|
|
184
114
|
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const ext = require('node:path').extname(new URL(url).pathname) || (type === 'image' ? '.jpg' : '.mp4')
|
|
115
|
+
const hash = createHash('md5').update(url).digest('hex')
|
|
116
|
+
const ext = path.extname(new URL(url).pathname) || (type === 'image' ? '.jpg' : '.mp4')
|
|
188
117
|
const filename = `${hash}${ext}`
|
|
189
|
-
const filePath =
|
|
118
|
+
const filePath = path.join(dir, filename)
|
|
190
119
|
|
|
191
|
-
if (!
|
|
120
|
+
if (!(await this.fileExists(filePath))) {
|
|
192
121
|
const buffer = await this.ctx.http.get(url, { responseType: 'arraybuffer' })
|
|
193
|
-
|
|
122
|
+
await fs.writeFile(filePath, Buffer.from(buffer))
|
|
194
123
|
}
|
|
195
124
|
|
|
196
125
|
// 返回 Vite @fs 路径格式,让浏览器通过 Vite 开发服务器加载本地文件
|
|
@@ -206,32 +135,8 @@ export class MessageHandler {
|
|
|
206
135
|
private processMediaElementsAsync(elements: h[], isUserMessage: boolean = true) {
|
|
207
136
|
if (!elements) return
|
|
208
137
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
for (const el of elements) {
|
|
212
|
-
if (['image', 'img', 'mface'].includes(el.type)) {
|
|
213
|
-
const src = el.attrs.src || el.attrs.url || el.attrs.file
|
|
214
|
-
if (src && isUserMessage) {
|
|
215
|
-
|
|
216
|
-
this.downloadAndCacheMedia(src, 'image').catch(e => {
|
|
217
|
-
this.logger.warn('缓存图片失败:', e)
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
} else if (el.type === 'audio') {
|
|
221
|
-
const src = el.attrs.src || el.attrs.url || el.attrs.file
|
|
222
|
-
if (src && isUserMessage) {
|
|
223
|
-
|
|
224
|
-
this.downloadAndCacheMedia(src, 'media').catch(e => {
|
|
225
|
-
this.logger.warn('缓存语音失败:', e)
|
|
226
|
-
})
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (el.children) this.processMediaElementsAsync(el.children, isUserMessage)
|
|
231
|
-
}
|
|
232
|
-
} catch (error) {
|
|
233
|
-
this.logger.error('处理媒体元素失败:', error)
|
|
234
|
-
}
|
|
138
|
+
this.scheduleTask('处理媒体元素', async () => {
|
|
139
|
+
await this.processMediaElements(elements, isUserMessage)
|
|
235
140
|
})
|
|
236
141
|
}
|
|
237
142
|
|
|
@@ -297,16 +202,22 @@ export class MessageHandler {
|
|
|
297
202
|
timestamp: timestamp,
|
|
298
203
|
channelId: session.channelId,
|
|
299
204
|
selfId: session.selfId,
|
|
300
|
-
elements:
|
|
205
|
+
elements: elements,
|
|
301
206
|
type: 'user',
|
|
302
207
|
guildName: guildName,
|
|
303
208
|
platform: session.platform || 'unknown',
|
|
304
|
-
quote: quoteInfo
|
|
209
|
+
quote: quoteInfo,
|
|
305
210
|
isDirect: !!isDirect
|
|
306
211
|
}
|
|
307
212
|
|
|
213
|
+
messageInfo.elements = await this.utils.cleanBase64ContentAsync(messageInfo.elements, false)
|
|
214
|
+
messageInfo.quote = messageInfo.quote ? await this.utils.cleanBase64ContentAsync(messageInfo.quote, false) : undefined
|
|
215
|
+
|
|
308
216
|
await this.fileManager.addMessageToFile(messageInfo)
|
|
309
217
|
|
|
218
|
+
const eventElements = await this.utils.cleanBase64ContentAsync(elements, false)
|
|
219
|
+
const eventQuote = quoteInfo ? await this.utils.cleanBase64ContentAsync(quoteInfo, false) : undefined
|
|
220
|
+
|
|
310
221
|
const messageEvent = {
|
|
311
222
|
type: 'message',
|
|
312
223
|
selfId: session.selfId,
|
|
@@ -320,8 +231,8 @@ export class MessageHandler {
|
|
|
320
231
|
timestamp: timestamp,
|
|
321
232
|
guildName: guildName,
|
|
322
233
|
channelType: session.type || 0,
|
|
323
|
-
elements:
|
|
324
|
-
quote:
|
|
234
|
+
elements: eventElements,
|
|
235
|
+
quote: eventQuote,
|
|
325
236
|
isDirect: session.isDirect,
|
|
326
237
|
bot: {
|
|
327
238
|
avatar: session.bot.user?.avatar,
|
|
@@ -357,12 +268,10 @@ export class MessageHandler {
|
|
|
357
268
|
const quoteMatch = content.match(/<quote id="([^"]+)"\/>/)
|
|
358
269
|
if (quoteMatch) {
|
|
359
270
|
const quoteId = quoteMatch[1]
|
|
360
|
-
|
|
361
|
-
const channelKey = `${session.selfId}:${finalChannelId}`
|
|
362
|
-
const quotedMsg = data.messages[channelKey]?.find(m => m.id === quoteId)
|
|
271
|
+
const quotedMsg = await this.fileManager.findChannelMessageById(session.selfId, finalChannelId, quoteId)
|
|
363
272
|
|
|
364
273
|
if (quotedMsg) {
|
|
365
|
-
const realId = quotedMsg.id.startsWith('bot-msg-') ?
|
|
274
|
+
const realId = quotedMsg.id.startsWith('bot-msg-') ? quotedMsg.realId : quotedMsg.id
|
|
366
275
|
|
|
367
276
|
quoteInfo = {
|
|
368
277
|
messageId: realId || quotedMsg.id,
|
|
@@ -396,7 +305,7 @@ export class MessageHandler {
|
|
|
396
305
|
timestamp: timestamp,
|
|
397
306
|
channelId: finalChannelId,
|
|
398
307
|
selfId: session.selfId,
|
|
399
|
-
elements: this.utils.
|
|
308
|
+
elements: await this.utils.cleanBase64ContentAsync(session.event?.message?.elements, true),
|
|
400
309
|
type: 'bot',
|
|
401
310
|
guildName: guildName,
|
|
402
311
|
platform: session.platform || 'unknown',
|
|
@@ -408,6 +317,8 @@ export class MessageHandler {
|
|
|
408
317
|
// 异步保存消息(不阻塞)
|
|
409
318
|
await this.fileManager.addMessageToFile(messageInfo)
|
|
410
319
|
|
|
320
|
+
const eventElements = await this.utils.cleanBase64ContentAsync(session.event?.message?.elements, true)
|
|
321
|
+
|
|
411
322
|
const messageEvent = {
|
|
412
323
|
type: 'bot-message',
|
|
413
324
|
selfId: session.selfId,
|
|
@@ -421,7 +332,7 @@ export class MessageHandler {
|
|
|
421
332
|
timestamp: timestamp,
|
|
422
333
|
guildName: guildName,
|
|
423
334
|
channelType: session.event?.channel?.type || session.type || 0,
|
|
424
|
-
elements:
|
|
335
|
+
elements: eventElements,
|
|
425
336
|
quote: quoteInfo,
|
|
426
337
|
isDirect: !!isDirect,
|
|
427
338
|
sending: true,
|
|
@@ -437,9 +348,160 @@ export class MessageHandler {
|
|
|
437
348
|
}
|
|
438
349
|
}
|
|
439
350
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
351
|
+
dispose() {
|
|
352
|
+
void this.utils.dispose()
|
|
353
|
+
for (const dispose of this.scheduledTasks) {
|
|
354
|
+
dispose()
|
|
355
|
+
}
|
|
356
|
+
this.scheduledTasks.clear()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private scheduleTask(label: string, task: () => Promise<void>) {
|
|
360
|
+
const dispose = this.ctx.setTimeout(() => {
|
|
361
|
+
this.scheduledTasks.delete(dispose)
|
|
362
|
+
void task().catch((error) => {
|
|
363
|
+
this.logger.error(`${label}失败:`, error)
|
|
364
|
+
})
|
|
365
|
+
}, 0)
|
|
366
|
+
|
|
367
|
+
this.scheduledTasks.add(dispose)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private shouldRefreshChannelInfo(
|
|
371
|
+
channelKey: string,
|
|
372
|
+
existingChannel: ChannelInfo | undefined,
|
|
373
|
+
isDirect: boolean,
|
|
374
|
+
channelId: string,
|
|
375
|
+
directUserName?: string
|
|
376
|
+
) {
|
|
377
|
+
if (this.channelRefreshInFlight.has(channelKey)) {
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const lastRefresh = this.lastChannelRefreshAt.get(channelKey) || 0
|
|
382
|
+
if (Date.now() - lastRefresh < this.CHANNEL_REFRESH_TTL_MS) {
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (isDirect) {
|
|
387
|
+
return !directUserName && (!existingChannel || existingChannel.name.includes('未知'))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return !existingChannel?.guildName || existingChannel.guildName === channelId
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async refreshChannelInfo(
|
|
394
|
+
session: Session,
|
|
395
|
+
existingChannel: ChannelInfo | undefined,
|
|
396
|
+
isDirect: boolean,
|
|
397
|
+
directUserName?: string
|
|
398
|
+
) {
|
|
399
|
+
const channelKey = `${session.selfId}:${session.channelId}`
|
|
400
|
+
this.channelRefreshInFlight.add(channelKey)
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
let guildName = existingChannel?.guildName || session.channelId
|
|
404
|
+
|
|
405
|
+
if (!isDirect) {
|
|
406
|
+
guildName = await this.resolveGuildName(session)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const finalName = this.buildChannelName(session, existingChannel, isDirect, directUserName, guildName)
|
|
410
|
+
const channelInfo: ChannelInfo = {
|
|
411
|
+
id: session.channelId,
|
|
412
|
+
name: finalName,
|
|
413
|
+
type: session.type || 0,
|
|
414
|
+
channelId: session.channelId,
|
|
415
|
+
guildName,
|
|
416
|
+
isDirect: !!isDirect
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await this.fileManager.upsertChannelInfo(session.selfId, session.channelId, channelInfo)
|
|
420
|
+
this.lastChannelRefreshAt.set(channelKey, Date.now())
|
|
421
|
+
this.logger.logInfo('更新频道信息到文件:', channelInfo.name)
|
|
422
|
+
} finally {
|
|
423
|
+
this.channelRefreshInFlight.delete(channelKey)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private async resolveGuildName(session: Session): Promise<string> {
|
|
428
|
+
try {
|
|
429
|
+
if (session.guildId && session.bot.getGuild && typeof session.bot.getGuild === 'function') {
|
|
430
|
+
const guild = await session.bot.getGuild(session.guildId)
|
|
431
|
+
return guild?.name || session.channelId
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (session.guildId && session.bot.getChannel && typeof session.bot.getChannel === 'function') {
|
|
435
|
+
const channel = await session.bot.getChannel(session.guildId)
|
|
436
|
+
return channel?.name || session.channelId
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
this.logger.logInfo('获取频道信息失败,使用频道ID作为备用:', error)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return session.channelId
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private buildChannelName(
|
|
446
|
+
session: Session,
|
|
447
|
+
existingChannel: ChannelInfo | undefined,
|
|
448
|
+
isDirect: boolean,
|
|
449
|
+
directUserName: string | undefined,
|
|
450
|
+
guildName: string
|
|
451
|
+
) {
|
|
452
|
+
if (isDirect) {
|
|
453
|
+
if (directUserName && directUserName !== session.userId) {
|
|
454
|
+
return `私聊(${directUserName})`
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (existingChannel?.name && !existingChannel.name.includes('未知')) {
|
|
458
|
+
return existingChannel.name
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (session.platform && session.platform.toLowerCase().includes('sandbox')) {
|
|
462
|
+
return `私聊(${session.userId})`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return '私聊(未知用户)'
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return guildName || session.channelId
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async processMediaElements(elements: h[], isUserMessage: boolean) {
|
|
472
|
+
for (const el of elements) {
|
|
473
|
+
if (['image', 'img', 'mface'].includes(el.type)) {
|
|
474
|
+
const src = el.attrs.src || el.attrs.url || el.attrs.file
|
|
475
|
+
if (src && isUserMessage) {
|
|
476
|
+
try {
|
|
477
|
+
await this.downloadAndCacheMedia(src, 'image')
|
|
478
|
+
} catch (error) {
|
|
479
|
+
this.logger.warn('缓存图片失败:', error)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} else if (el.type === 'audio') {
|
|
483
|
+
const src = el.attrs.src || el.attrs.url || el.attrs.file
|
|
484
|
+
if (src && isUserMessage) {
|
|
485
|
+
try {
|
|
486
|
+
await this.downloadAndCacheMedia(src, 'media')
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.logger.warn('缓存语音失败:', error)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (el.children?.length) {
|
|
494
|
+
await this.processMediaElements(el.children, isUserMessage)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async fileExists(filePath: string) {
|
|
500
|
+
try {
|
|
501
|
+
await fs.access(filePath)
|
|
502
|
+
return true
|
|
503
|
+
} catch {
|
|
504
|
+
return false
|
|
443
505
|
}
|
|
444
506
|
}
|
|
445
507
|
}
|
package/src/utils.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Config } from './config'
|
|
2
2
|
import { Context } from 'koishi'
|
|
3
|
-
import { writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
4
3
|
import { join } from 'node:path'
|
|
5
4
|
import { createHash } from 'node:crypto'
|
|
6
5
|
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { promises as fs } from 'node:fs'
|
|
7
7
|
|
|
8
8
|
export class Utils {
|
|
9
|
+
private persistImageWriteCount = 0
|
|
10
|
+
|
|
11
|
+
private pendingTasks: Set<Promise<void>> = new Set()
|
|
12
|
+
|
|
9
13
|
constructor(private config: Config, private ctx?: Context) { }
|
|
10
14
|
|
|
11
15
|
isPlatformBlocked(platform: string): boolean {
|
|
@@ -59,93 +63,127 @@ export class Utils {
|
|
|
59
63
|
return false
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
async persistBase64ImageAsync(base64Data: string): Promise<string> {
|
|
63
67
|
if (!this.ctx || !base64Data.startsWith('data:image/')) return base64Data
|
|
64
68
|
|
|
65
69
|
try {
|
|
66
70
|
const dir = join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-images')
|
|
67
|
-
|
|
71
|
+
await fs.mkdir(dir, { recursive: true })
|
|
68
72
|
|
|
69
73
|
const hash = createHash('md5').update(base64Data).digest('hex')
|
|
70
74
|
const ext = base64Data.split(';')[0].split('/')[1] || 'png'
|
|
71
|
-
const filename = `${
|
|
75
|
+
const filename = `${hash}.${ext}`
|
|
72
76
|
const filePath = join(dir, filename)
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
if (!(await this.fileExists(filePath))) {
|
|
79
|
+
const base64Content = base64Data.split(',')[1]
|
|
80
|
+
await fs.writeFile(filePath, Buffer.from(base64Content, 'base64'))
|
|
81
|
+
}
|
|
76
82
|
|
|
77
|
-
this.
|
|
83
|
+
this.persistImageWriteCount += 1
|
|
84
|
+
if (this.persistImageWriteCount % 20 === 0) {
|
|
85
|
+
this.trackTask(this.cleanupPersistImagesAsync(dir))
|
|
86
|
+
}
|
|
78
87
|
|
|
79
88
|
return pathToFileURL(filePath).href
|
|
80
|
-
} catch
|
|
89
|
+
} catch {
|
|
81
90
|
return base64Data
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
private
|
|
94
|
+
private async cleanupPersistImagesAsync(dir: string) {
|
|
86
95
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
const fileNames = await fs.readdir(dir)
|
|
97
|
+
const files = await Promise.all(fileNames.map(async (name) => {
|
|
98
|
+
const filePath = join(dir, name)
|
|
99
|
+
const stats = await fs.stat(filePath)
|
|
100
|
+
return { path: filePath, mtime: stats.mtimeMs }
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
104
|
+
|
|
105
|
+
for (const file of files.slice(this.config.maxPersistImages)) {
|
|
106
|
+
try {
|
|
107
|
+
await fs.unlink(file.path)
|
|
108
|
+
} catch {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
93
111
|
}
|
|
94
|
-
} catch
|
|
112
|
+
} catch { }
|
|
95
113
|
}
|
|
96
114
|
|
|
97
|
-
|
|
115
|
+
async cleanBase64ContentAsync<T>(obj: T, isBotMessage: boolean = false): Promise<T> {
|
|
98
116
|
if (obj === null || obj === undefined) {
|
|
99
117
|
return obj
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
if (typeof obj === 'string') {
|
|
103
121
|
if (this.isBase64(obj)) {
|
|
104
|
-
|
|
105
122
|
if (isBotMessage && !obj.startsWith('data:image/')) {
|
|
106
|
-
return '[富媒体内容已省略]'
|
|
123
|
+
return '[富媒体内容已省略]' as T
|
|
107
124
|
}
|
|
108
|
-
return this.
|
|
125
|
+
return await this.persistBase64ImageAsync(obj) as T
|
|
109
126
|
}
|
|
110
127
|
return obj
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
if (Array.isArray(obj)) {
|
|
114
|
-
|
|
131
|
+
const cleanedItems = await Promise.all(obj.map(item => this.cleanBase64ContentAsync(item, isBotMessage)))
|
|
132
|
+
return cleanedItems as T
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
if (typeof obj === 'object') {
|
|
118
|
-
const cleaned:
|
|
119
|
-
|
|
136
|
+
const cleaned: Record<string, unknown> = {}
|
|
137
|
+
const source = obj as Record<string, unknown>
|
|
120
138
|
|
|
121
|
-
|
|
139
|
+
for (const [key, value] of Object.entries(source)) {
|
|
140
|
+
const type = typeof source.type === 'string' ? source.type : undefined
|
|
122
141
|
|
|
142
|
+
if (isBotMessage && type && !['text', 'image', 'img'].includes(type)) {
|
|
123
143
|
if (typeof value === 'string' && (key === 'src' || key === 'url' || key === 'file') && this.isBase64(value)) {
|
|
124
144
|
cleaned[key] = '[富媒体内容已省略]'
|
|
125
145
|
continue
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
key === 'url' ||
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
key === 'content'
|
|
135
|
-
) && this.isBase64(value)) {
|
|
136
|
-
|
|
149
|
+
if (
|
|
150
|
+
typeof value === 'string'
|
|
151
|
+
&& (key === 'src' || key === 'url' || key === 'file' || key === 'data' || key === 'content')
|
|
152
|
+
&& this.isBase64(value)
|
|
153
|
+
) {
|
|
137
154
|
if (isBotMessage && !value.startsWith('data:image/')) {
|
|
138
155
|
cleaned[key] = '[富媒体内容已省略]'
|
|
139
156
|
} else {
|
|
140
|
-
cleaned[key] = this.
|
|
157
|
+
cleaned[key] = await this.persistBase64ImageAsync(value)
|
|
141
158
|
}
|
|
142
159
|
} else {
|
|
143
|
-
cleaned[key] = this.
|
|
160
|
+
cleaned[key] = await this.cleanBase64ContentAsync(value, isBotMessage)
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
|
-
|
|
163
|
+
|
|
164
|
+
return cleaned as T
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
return obj
|
|
150
168
|
}
|
|
169
|
+
|
|
170
|
+
async dispose() {
|
|
171
|
+
await Promise.allSettled([...this.pendingTasks])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private trackTask(task: Promise<void>) {
|
|
175
|
+
this.pendingTasks.add(task)
|
|
176
|
+
void task.finally(() => {
|
|
177
|
+
this.pendingTasks.delete(task)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async fileExists(filePath: string) {
|
|
182
|
+
try {
|
|
183
|
+
await fs.access(filePath)
|
|
184
|
+
return true
|
|
185
|
+
} catch {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
}
|
|
151
189
|
}
|