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.
@@ -1,46 +1,51 @@
1
1
  import { BotInfo, ChannelInfo, MessageInfo, QuoteInfo } from './types'
2
- import { Context, Session, h, Logger } from 'koishi'
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
- setImmediate(() => {
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
- setImmediate(() => {
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
- setImmediate(() => {
53
- try {
54
- const data = this.fileManager.readChatDataFromFile()
55
-
56
- const botInfo: BotInfo = {
57
- selfId: session.selfId,
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 data = this.fileManager.readChatDataFromFile()
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
- setImmediate(async () => {
96
- try {
97
- let guildName = session.channelId
98
-
99
- if (!isDirect) {
100
- try {
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 = require('node:path').join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-media', folder)
181
- if (!require('node:fs').existsSync(dir)) {
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 crypto = require('node:crypto')
186
- const hash = crypto.createHash('md5').update(url).digest('hex')
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 = require('node:path').join(dir, filename)
118
+ const filePath = path.join(dir, filename)
190
119
 
191
- if (!require('node:fs').existsSync(filePath)) {
120
+ if (!(await this.fileExists(filePath))) {
192
121
  const buffer = await this.ctx.http.get(url, { responseType: 'arraybuffer' })
193
- require('node:fs').writeFileSync(filePath, Buffer.from(buffer))
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
- setImmediate(async () => {
210
- try {
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: this.utils.cleanBase64Content(elements, false),
205
+ elements: elements,
301
206
  type: 'user',
302
207
  guildName: guildName,
303
208
  platform: session.platform || 'unknown',
304
- quote: quoteInfo ? this.utils.cleanBase64Content(quoteInfo, false) : undefined,
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: this.utils.cleanBase64Content(elements, false),
324
- quote: quoteInfo ? this.utils.cleanBase64Content(quoteInfo, false) : undefined,
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
- const data = this.fileManager.readChatDataFromFile()
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-') ? (quotedMsg as any).realId : quotedMsg.id
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.cleanBase64Content(session.event?.message?.elements, true),
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: this.utils.cleanBase64Content(session.event?.message?.elements, true),
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
- private logInfo(...args: any[]) {
441
- if (this.config.loggerinfo) {
442
- (this.logger.info as (...args: any[]) => void)(...args)
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
- persistBase64Image(base64Data: string): string {
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
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
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 = `${Date.now()}_${hash}.${ext}`
75
+ const filename = `${hash}.${ext}`
72
76
  const filePath = join(dir, filename)
73
77
 
74
- const base64Content = base64Data.split(',')[1]
75
- writeFileSync(filePath, Buffer.from(base64Content, 'base64'))
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.cleanupPersistImages(dir)
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 (e) {
89
+ } catch {
81
90
  return base64Data
82
91
  }
83
92
  }
84
93
 
85
- private cleanupPersistImages(dir: string) {
94
+ private async cleanupPersistImagesAsync(dir: string) {
86
95
  try {
87
- const files = readdirSync(dir)
88
- .map(name => ({ name, path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs }))
89
- .sort((a, b) => b.mtime - a.mtime)
90
-
91
- if (files.length > this.config.maxPersistImages) {
92
- files.slice(this.config.maxPersistImages).forEach(f => unlinkSync(f.path))
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 (e) { }
112
+ } catch { }
95
113
  }
96
114
 
97
- cleanBase64Content(obj: any, isBotMessage: boolean = false): any {
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.persistBase64Image(obj)
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
- return obj.map(item => this.cleanBase64Content(item, isBotMessage))
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: any = {}
119
- for (const [key, value] of Object.entries(obj)) {
136
+ const cleaned: Record<string, unknown> = {}
137
+ const source = obj as Record<string, unknown>
120
138
 
121
- if (isBotMessage && obj.type && !['text', 'image', 'img'].includes(obj.type)) {
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 (typeof value === 'string' && (
130
- key === 'src' ||
131
- key === 'url' ||
132
- key === 'file' ||
133
- key === 'data' ||
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.persistBase64Image(value)
157
+ cleaned[key] = await this.persistBase64ImageAsync(value)
141
158
  }
142
159
  } else {
143
- cleaned[key] = this.cleanBase64Content(value, isBotMessage)
160
+ cleaned[key] = await this.cleanBase64ContentAsync(value, isBotMessage)
144
161
  }
145
162
  }
146
- return cleaned
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
  }