koishi-plugin-chat-patch 2.1.1 → 2.2.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,704 +1,750 @@
1
- import { FileManager } from './file-manager'
2
- import { MessageHandler } from './message-handler'
3
- import { Context, h, Logger, Universal } from 'koishi'
4
- import { Config } from './config'
5
- import { } from '@koishijs/plugin-console'
6
- import { URL, pathToFileURL, fileURLToPath } from 'node:url'
7
- import { readFileSync } from 'node:fs'
8
- import * as mime from 'mime-types'
9
-
10
- export class ApiHandlers {
11
- private logger: Logger
12
-
13
- constructor(
14
- private ctx: Context,
15
- private config: Config,
16
- private fileManager: FileManager,
17
- private messageHandler: MessageHandler
18
- ) {
19
- this.logger = ctx.logger('chat-patch')
20
- }
21
-
22
- registerApiHandlers() {
23
- this.ctx.console.addListener('clear-all-indexeddb-data' as any, async () => {
24
- try {
25
- this.logInfo('收到清空 IndexedDB 数据请求')
26
- // 这个 API 主要用于前端调用,后端不直接操作 IndexedDB
27
- return { success: true, message: '可以清空 IndexedDB' }
28
- } catch (error: any) {
29
- this.logger.error('清空 IndexedDB 数据失败:', error)
30
- return { success: false, error: error?.message || String(error) }
31
- }
32
- })
33
-
34
- // 获取所有聊天数据的 API
35
- this.ctx.console.addListener('get-chat-data' as any, async () => {
36
- try {
37
- // 优化性能:只读取基础信息,不读取庞大的消息体
38
- const data = this.fileManager.readChatDataFromFile()
39
-
40
- this.logInfo('获取基础聊天数据')
41
-
42
- return {
43
- success: true,
44
- data: {
45
- bots: data.bots || {},
46
- channels: data.channels || {},
47
- pinnedBots: data.pinnedBots || [],
48
- pinnedChannels: data.pinnedChannels || [],
49
- // 不返回 messages,由前端按需拉取
50
- messages: {}
51
- }
52
- }
53
- } catch (error: any) {
54
- this.logger.error('获取聊天数据失败:', error)
55
- return { success: false, error: error?.message || String(error) }
56
- }
57
- })
58
-
59
- // 获取历史消息的 API
60
- this.ctx.console.addListener('get-history-messages' as any, async (requestData: {
61
- selfId: string
62
- channelId: string
63
- limit?: number
64
- offset?: number
65
- }) => {
66
- try {
67
- const data = this.fileManager.readChatDataFromFile()
68
- const channelKey = `${requestData.selfId}:${requestData.channelId}`
69
- let messages = data.messages[channelKey] || []
70
-
71
- // 按时间戳降序排列(最新的消息在前面)
72
- const sortedMessages = messages.sort((a, b) => b.timestamp - a.timestamp)
73
-
74
- // 如果提供了分页参数,则进行分页处理
75
- if (requestData.limit !== undefined) {
76
- const limit = requestData.limit
77
- const offset = requestData.offset || 0
78
- messages = sortedMessages.slice(offset, offset + limit)
79
- // 重新按时间戳升序排列,以保持消息显示顺序
80
- messages = messages.sort((a, b) => a.timestamp - b.timestamp)
81
- } else {
82
- // 默认返回所有消息,按时间戳升序排列
83
- messages = sortedMessages.sort((a, b) => a.timestamp - b.timestamp)
84
- }
85
-
86
- this.logInfo('获取历史消息:', channelKey, '共', messages.length, '条消息')
87
-
88
- return {
89
- success: true,
90
- messages: messages,
91
- total: sortedMessages.length
92
- }
93
- } catch (error: any) {
94
- this.logger.error('获取历史消息失败:', error)
95
- return { success: false, error: error?.message || String(error), messages: [], total: 0 }
96
- }
97
- })
98
-
99
- // 获取所有频道消息数量的 API
100
- this.ctx.console.addListener('get-all-channel-message-counts' as any, async () => {
101
- try {
102
- const data = this.fileManager.readChatDataFromFile()
103
- const counts: Record<string, number> = {}
104
-
105
- for (const [channelKey, messages] of Object.entries(data.messages)) {
106
- counts[channelKey] = messages.length
107
- }
108
-
109
- this.logInfo('获取所有频道消息数量:', {
110
- 频道数: Object.keys(counts).length,
111
- 总消息数: Object.values(counts).reduce((total, count) => total + count, 0)
112
- })
113
-
114
- return {
115
- success: true,
116
- counts: counts
117
- }
118
- } catch (error: any) {
119
- this.logger.error('获取频道消息数量失败:', error)
120
- return { success: false, error: error?.message || String(error), counts: {} }
121
- }
122
- })
123
-
124
- // 图片获取 API
125
- this.ctx.console.addListener('fetch-image' as any, async (data: { url: string }) => {
126
- try {
127
- // 检查是否是本地文件路径
128
- if (this.isFileUrl(data.url)) {
129
- this.logInfo('处理本地文件请求:', data.url)
130
- return await this.handleLocalFileRequest(data.url)
131
- }
132
-
133
- // 处理网络图片
134
- const response = await fetch(data.url, {
135
- headers: {
136
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
137
- 'Referer': ''
138
- }
139
- })
140
-
141
- if (!response.ok) {
142
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
143
- }
144
-
145
- const buffer = await response.arrayBuffer()
146
- const base64 = Buffer.from(buffer).toString('base64')
147
- const contentType = response.headers.get('content-type') || 'image/jpeg'
148
- return {
149
- success: true,
150
- base64: base64,
151
- contentType: contentType,
152
- dataUrl: `data:${contentType};base64,${base64}`
153
- }
154
- } catch (error: any) {
155
- return { success: false, error: error?.message || String(error) }
156
- }
157
- })
158
-
159
- // 清理频道历史记录的 API
160
- this.ctx.console.addListener('clear-channel-history' as any, async (data: {
161
- selfId: string
162
- channelId: string
163
- keepCount?: number
164
- }) => {
165
- try {
166
- this.logInfo('收到清理历史记录请求:', data)
167
-
168
- const chatData = this.fileManager.readChatDataFromFile()
169
- const channelKey = `${data.selfId}:${data.channelId}`
170
-
171
- if (!chatData.messages[channelKey]) {
172
- return { success: true, message: '频道没有历史消息' }
173
- }
174
-
175
- const messages = chatData.messages[channelKey]
176
- const originalCount = messages.length
177
-
178
- const keepCount = data.keepCount || this.config.keepMessagesOnClear
179
-
180
- if (keepCount > 0 && originalCount <= keepCount) {
181
- return { success: true, message: `消息数量(${originalCount})未超过保留数量(${keepCount}),无需清理` }
182
- }
183
-
184
- const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
185
- const keptMessages = keepCount > 0 ? sortedMessages.slice(-keepCount) : []
186
- const clearedCount = originalCount - keptMessages.length
187
-
188
- chatData.messages[channelKey] = keptMessages
189
- this.fileManager.writeChatDataToFile(chatData)
190
-
191
- this.logInfo(`频道 ${channelKey} 历史记录清理完成:`, {
192
- 原始消息数: originalCount,
193
- 保留消息数: keptMessages.length,
194
- 清理消息数: clearedCount
195
- })
196
-
197
- return {
198
- success: true,
199
- message: `成功清理 ${clearedCount} 条历史消息,保留最新 ${keptMessages.length} 条`,
200
- clearedCount: clearedCount,
201
- keptCount: keptMessages.length
202
- }
203
- } catch (error: any) {
204
- this.logger.error('清理频道历史记录失败:', error)
205
- return { success: false, error: error?.message || String(error) }
206
- }
207
- })
208
-
209
- // 发送消息的 API
210
- this.ctx.console.addListener('send-message' as any, async (data: {
211
- selfId: string
212
- channelId: string
213
- content: string
214
- images?: Array<{
215
- tempId: string
216
- filename: string
217
- }>
218
- }) => {
219
- try {
220
- this.logInfo('收到发送消息请求:', data)
221
-
222
- // 优化机器人查找逻辑
223
- const bot = this.ctx.bots.find((bot: any) => bot.selfId === data.selfId || bot.user?.id === data.selfId)
224
- if (!bot) {
225
- this.logger.error('未找到机器人:', data.selfId, '当前可用机器人:', this.ctx.bots.map((b: any) => b.selfId))
226
- return { success: false, error: `未找到机器人 ${data.selfId},请检查机器人是否在线` }
227
- }
228
-
229
- // 检查机器人状态
230
- if (bot.status !== Universal.Status.ONLINE) {
231
- this.logger.error('机器人离线:', data.selfId, '状态:', bot.status)
232
- return { success: false, error: `机器人 ${data.selfId} 当前离线` }
233
- }
234
-
235
- let messageContent = data.content
236
-
237
- // 如果有图片,添加图片元素
238
- if (data.images && data.images.length > 0) {
239
- const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
240
-
241
- for (const image of data.images) {
242
- const files = require('fs').readdirSync(tempDir).filter((file: string) =>
243
- file.includes(`temp_${image.tempId}`)
244
- )
245
-
246
- if (files.length > 0) {
247
- const imagePath = `${tempDir}/${files[0]}`
248
- // 使用 pathToFileURL 创建正确的文件 URL
249
- const fileUrl = this.createFileUrl(imagePath)
250
- messageContent += h.image(fileUrl).toString()
251
- this.logInfo('添加图片到消息:', { imagePath, fileUrl })
252
- }
253
- }
254
- }
255
-
256
- const parsedContent = h.parse(messageContent)
257
-
258
- // 在发送消息前,通知 MessageHandler 正确的 channelId
259
- this.messageHandler.setCorrectChannelId(data.selfId, data.channelId)
260
-
261
- const result = await bot.sendMessage(data.channelId, parsedContent)
262
- this.logInfo('消息发送成功:', result)
263
-
264
- const messageId = Array.isArray(result) ? result[0] : result;
265
-
266
- // 发送成功后,更新本地存储中的虚拟消息为真实 ID
267
- if (messageId) {
268
- const chatData = this.fileManager.readChatDataFromFile()
269
- const channelKey = `${data.selfId}:${data.channelId}`
270
- const messages = chatData.messages[channelKey] || []
271
- // 找到最近的一条正在发送的机器人消息
272
- const msg = [...messages].reverse().find(m => m.type === 'bot' && m.sending)
273
- if (msg) {
274
- msg.realId = messageId;
275
- msg.sending = false;
276
- this.fileManager.writeChatDataToFile(chatData)
277
-
278
- // 广播更新事件给前端
279
- this.ctx.console.broadcast('bot-message-updated', {
280
- channelKey,
281
- tempId: msg.id,
282
- realId: messageId
283
- })
284
- }
285
- }
286
-
287
- return {
288
- success: !!messageId,
289
- messageId: messageId,
290
- tempImageIds: data.images?.map(img => img.tempId) || []
291
- }
292
- } catch (error: any) {
293
- this.logger.error('发送消息失败:', error)
294
- return { success: false, error: error?.message || String(error) }
295
- }
296
- })
297
-
298
- // 清理临时图片的 API(由前端在消息发送成功后调用)
299
- this.ctx.console.addListener('cleanup-temp-images' as any, async (data: {
300
- tempImageIds: string[]
301
- }) => {
302
- try {
303
- this.logInfo('收到清理临时图片请求:', data.tempImageIds)
304
-
305
- // 不立即删除,只是记录日志,让定时任务处理清理
306
- this.logInfo('临时图片将由定时任务清理,保持文件可用性')
307
-
308
- return { success: true, cleanedCount: 0 }
309
- } catch (error: any) {
310
- this.logger.error('清理临时图片失败:', error)
311
- return { success: false, error: error?.message || String(error) }
312
- }
313
- })
314
-
315
- // 删除机器人所有数据的 API
316
- this.ctx.console.addListener('delete-bot-data' as any, async (data: {
317
- selfId: string
318
- }) => {
319
- try {
320
- this.logInfo('收到删除机器人数据请求:', data)
321
-
322
- const chatData = this.fileManager.readChatDataFromFile()
323
- let deletedChannels = 0
324
- let deletedMessages = 0
325
-
326
- if (chatData.channels[data.selfId]) {
327
- deletedChannels = Object.keys(chatData.channels[data.selfId]).length
328
- delete chatData.channels[data.selfId]
329
- }
330
-
331
- const channelsToDelete = Object.keys(chatData.messages).filter(key => key.startsWith(`${data.selfId}:`))
332
- for (const channelKey of channelsToDelete) {
333
- deletedMessages += chatData.messages[channelKey].length
334
- delete chatData.messages[channelKey]
335
- }
336
-
337
- delete chatData.bots[data.selfId]
338
- this.fileManager.writeChatDataToFile(chatData)
339
-
340
- this.logInfo(`机器人 ${data.selfId} 数据删除完成:`, {
341
- 删除频道数: deletedChannels,
342
- 删除消息数: deletedMessages
343
- })
344
-
345
- return {
346
- success: true,
347
- message: `成功删除机器人数据:${deletedChannels} 个频道,${deletedMessages} 条消息`,
348
- deletedChannels,
349
- deletedMessages
350
- }
351
- } catch (error: any) {
352
- this.logger.error('删除机器人数据失败:', error)
353
- return { success: false, error: error?.message || String(error) }
354
- }
355
- })
356
-
357
- // 删除频道所有数据的 API
358
- this.ctx.console.addListener('delete-channel-data' as any, async (data: {
359
- selfId: string
360
- channelId: string
361
- }) => {
362
- try {
363
- this.logInfo('收到删除频道数据请求:', data)
364
-
365
- const chatData = this.fileManager.readChatDataFromFile()
366
- const channelKey = `${data.selfId}:${data.channelId}`
367
- let deletedMessages = 0
368
-
369
- if (chatData.messages[channelKey]) {
370
- deletedMessages = chatData.messages[channelKey].length
371
- delete chatData.messages[channelKey]
372
- }
373
-
374
- if (chatData.channels[data.selfId] && chatData.channels[data.selfId][data.channelId]) {
375
- delete chatData.channels[data.selfId][data.channelId]
376
- }
377
-
378
- this.fileManager.writeChatDataToFile(chatData)
379
-
380
- this.logInfo(`频道 ${channelKey} 数据删除完成:`, {
381
- 删除消息数: deletedMessages
382
- })
383
-
384
- return {
385
- success: true,
386
- message: `成功删除频道数据:${deletedMessages} 条消息`,
387
- deletedMessages
388
- }
389
- } catch (error: any) {
390
- this.logger.error('删除频道数据失败:', error)
391
- return { success: false, error: error?.message || String(error) }
392
- }
393
- })
394
-
395
- // 设置置顶机器人列表的 API
396
- this.ctx.console.addListener('set-pinned-bots' as any, async (data: {
397
- pinnedBots: string[]
398
- }) => {
399
- try {
400
- this.logInfo('收到设置置顶机器人请求:', data.pinnedBots)
401
- const chatData = this.fileManager.readChatDataFromFile()
402
- chatData.pinnedBots = data.pinnedBots
403
- this.fileManager.writeChatDataToFile(chatData)
404
- return { success: true }
405
- } catch (error: any) {
406
- this.logger.error('设置置顶机器人失败:', error)
407
- return { success: false, error: error?.message || String(error) }
408
- }
409
- })
410
-
411
- // 设置置顶频道列表的 API
412
- this.ctx.console.addListener('set-pinned-channels' as any, async (data: {
413
- pinnedChannels: string[]
414
- }) => {
415
- try {
416
- this.logInfo('收到设置置顶频道请求:', data.pinnedChannels)
417
- const chatData = this.fileManager.readChatDataFromFile()
418
- chatData.pinnedChannels = data.pinnedChannels
419
- this.fileManager.writeChatDataToFile(chatData)
420
- return { success: true }
421
- } catch (error: any) {
422
- this.logger.error('设置置顶频道失败:', error)
423
- return { success: false, error: error?.message || String(error) }
424
- }
425
- })
426
-
427
- // 上传图片的 API
428
- this.ctx.console.addListener('upload-image' as any, async (data: {
429
- file: string // base64 encoded image
430
- filename: string
431
- mimeType: string
432
- isGif?: boolean // 是否为GIF图片
433
- }) => {
434
- try {
435
- this.logInfo('收到图片上传请求:', { filename: data.filename, mimeType: data.mimeType, isGif: data.isGif })
436
-
437
- // 将base64转换为Buffer
438
- const base64Data = data.file.replace(/^data:image\/\w+;base64,/, '')
439
- const buffer = Buffer.from(base64Data, 'base64')
440
-
441
- // 生成临时文件路径,保持原始扩展名以保持GIF格式
442
- const tempId = Date.now() + '_' + Math.random().toString(36).substring(2, 11)
443
- const extension = data.filename.split('.').pop()?.toLowerCase() || (data.isGif ? 'gif' : 'jpg')
444
- const tempFilename = `temp_${tempId}.${extension}`
445
-
446
- // 保存到临时目录
447
- const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
448
- if (!require('fs').existsSync(tempDir)) {
449
- require('fs').mkdirSync(tempDir, { recursive: true })
450
- }
451
-
452
- const tempPath = `${tempDir}/${tempFilename}`
453
-
454
- // 对于GIF文件,直接保存原始数据以保持动画
455
- require('fs').writeFileSync(tempPath, buffer)
456
-
457
- this.logInfo('图片上传成功:', { tempPath, size: buffer.length, isGif: data.isGif })
458
-
459
- return {
460
- success: true,
461
- tempId: tempId,
462
- tempPath: tempPath,
463
- filename: data.filename,
464
- size: buffer.length,
465
- isGif: data.isGif
466
- }
467
- } catch (error: any) {
468
- this.logger.error('图片上传失败:', error)
469
- return { success: false, error: error?.message || String(error) }
470
- }
471
- })
472
-
473
- // 删除临时图片的 API
474
- this.ctx.console.addListener('delete-temp-image' as any, async (data: {
475
- tempId: string
476
- }) => {
477
- try {
478
- const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
479
- const files = require('fs').readdirSync(tempDir).filter((file: string) =>
480
- file.includes(`temp_${data.tempId}`)
481
- )
482
-
483
- for (const file of files) {
484
- const filePath = `${tempDir}/${file}`
485
- if (require('fs').existsSync(filePath)) {
486
- require('fs').unlinkSync(filePath)
487
- this.logInfo('删除临时图片:', filePath)
488
- }
489
- }
490
-
491
- return { success: true }
492
- } catch (error: any) {
493
- this.logger.error('删除临时图片失败:', error)
494
- return { success: false, error: error?.message || String(error) }
495
- }
496
- })
497
-
498
- // 定时清理过期临时文件(备用机制)
499
- this.setupTempFileCleanup()
500
-
501
- // 获取插件配置的 API
502
- this.ctx.console.addListener('get-plugin-config' as any, async () => {
503
- try {
504
- return {
505
- success: true,
506
- config: {
507
- maxMessagesPerChannel: this.config.maxMessagesPerChannel,
508
- keepMessagesOnClear: this.config.keepMessagesOnClear,
509
- maxPersistImages: this.config.maxPersistImages,
510
- loggerinfo: this.config.loggerinfo,
511
- blockedPlatforms: this.config.blockedPlatforms || [],
512
- clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
513
- }
514
- }
515
- } catch (error: any) {
516
- this.logger.error('获取插件配置失败:', error)
517
- return { success: false, error: error?.message || String(error) }
518
- }
519
- })
520
-
521
- // 获取用户信息 API
522
- this.ctx.console.addListener('get-user-info' as any, async (data: { selfId: string, userId: string, guildId?: string }) => {
523
- try {
524
- const bot = this.ctx.bots.find(b => b.selfId === data.selfId)
525
- if (!bot) return { success: false, error: '机器人不存在' }
526
-
527
- // 检查平台是否支持 getUser 方法
528
- if (!bot.getUser || typeof bot.getUser !== 'function') {
529
- return { success: false, error: '此平台不支持查看用户信息' }
530
- }
531
-
532
- const user = await bot.getUser(data.userId, data.guildId)
533
-
534
- if (user) {
535
- // 缓存头像到本地
536
- if (user.avatar) {
537
- user.avatar = await this.messageHandler.downloadAndCacheMedia(user.avatar, 'avatar')
538
- }
539
-
540
- const chatData = this.fileManager.readChatDataFromFile()
541
- let changed = false
542
-
543
- // 1. 如果是私聊,更新频道名 - 支持多种私聊ID格式
544
- const botChannels = chatData.channels[data.selfId] || {}
545
-
546
- // 查找所有可能的私聊频道ID格式
547
- const possibleChannelIds = [
548
- data.userId,
549
- `private:${data.userId}`,
550
- `direct:${data.userId}`
551
- ]
552
-
553
- for (const channelId of possibleChannelIds) {
554
- const channel = botChannels[channelId]
555
- if (channel && channel.isDirect) {
556
- const newName = `私聊(${user.name})`
557
- if (channel.name !== newName) {
558
- channel.name = newName
559
- changed = true
560
- this.logInfo('更新私聊频道名称:', { channelId, oldName: channel.name, newName })
561
- }
562
- }
563
- }
564
-
565
- // 2. 更新该用户在所有历史消息中的头像和昵称(持久化缓存)
566
- const channelKeyPrefix = `${data.selfId}:`
567
- for (const [key, messages] of Object.entries(chatData.messages)) {
568
- if (key.startsWith(channelKeyPrefix)) {
569
- messages.forEach(msg => {
570
- if (msg.userId === data.userId) {
571
- if (user.name && msg.username !== user.name) {
572
- msg.username = user.name
573
- changed = true
574
- }
575
- if (user.avatar && msg.avatar !== user.avatar) {
576
- msg.avatar = user.avatar
577
- changed = true
578
- }
579
- }
580
- });
581
- }
582
- }
583
-
584
- if (changed) {
585
- this.fileManager.writeChatDataToFile(chatData)
586
- // 广播更新事件,让前端实时刷新
587
- this.ctx.console.broadcast('chat-data-updated', {})
588
- }
589
- }
590
-
591
- return { success: true, data: user }
592
- } catch (error: any) {
593
- return { success: false, error: error?.message || '获取用户信息失败' }
594
- }
595
- })
596
-
597
- // 调试API:获取原始文件数据
598
- this.ctx.console.addListener('debug-get-raw-data' as any, async () => {
599
- try {
600
- const data = this.fileManager.readChatDataFromFile()
601
- return {
602
- success: true,
603
- data: data
604
- }
605
- } catch (error: any) {
606
- this.logger.error('获取原始数据失败:', error)
607
- return { success: false, error: error?.message || String(error) }
608
- }
609
- })
610
- }
611
-
612
- // 检查是否为文件 URL
613
- private isFileUrl(url: string): boolean {
614
- try {
615
- const parsedUrl = new URL(url)
616
- return parsedUrl.protocol === 'file:'
617
- } catch {
618
- return false
619
- }
620
- }
621
-
622
- // 创建文件 URL
623
- private createFileUrl(filePath: string): string {
624
- try {
625
- return pathToFileURL(filePath).href
626
- } catch (error) {
627
- this.logger.error('创建文件URL失败:', { filePath, error })
628
- // 回退到简单的字符串拼接
629
- return `file://${filePath}`
630
- }
631
- }
632
-
633
- // 处理本地文件请求
634
- private async handleLocalFileRequest(fileUrl: string) {
635
- try {
636
- // 使用 fileURLToPath 转换 file:// URL 为系统路径
637
- const filePath = fileURLToPath(fileUrl)
638
-
639
- // 改用 node:fs 直接读取文件,避免 ctx.http.file 可能存在的编码或协议处理问题
640
- const buffer = readFileSync(filePath)
641
- const base64 = buffer.toString('base64')
642
-
643
- // 使用 mime-types 库精确推断 MIME 类型
644
- const contentType = mime.lookup(filePath) || 'application/octet-stream'
645
-
646
- this.logInfo('成功读取本地文件:', { fileUrl, filePath, contentType })
647
-
648
- return {
649
- success: true,
650
- base64: base64,
651
- contentType: contentType,
652
- dataUrl: `data:${contentType};base64,${base64}`
653
- }
654
- } catch (error: any) {
655
- this.logger.error('读取本地文件失败:', { fileUrl, error: error.message })
656
- return {
657
- success: false,
658
- error: `读取本地文件失败: ${error.message}`
659
- }
660
- }
661
- }
662
-
663
- // 设置定时清理临时文件
664
- private setupTempFileCleanup() {
665
- // 每5分钟执行一次基于数量的清理
666
- setInterval(() => {
667
- this.cleanupMediaCache()
668
- }, 5 * 60 * 1000)
669
- }
670
-
671
- // 统一清理媒体缓存
672
- private async cleanupMediaCache() {
673
- const baseDir = this.ctx.baseDir + '/data/chat-patch/persist-media'
674
- if (!require('node:fs').existsSync(baseDir)) return
675
-
676
- const cleanupDir = (dirName: string, limit: number) => {
677
- const dirPath = require('node:path').join(baseDir, dirName)
678
- if (!require('node:fs').existsSync(dirPath)) return
679
-
680
- const files = require('node:fs').readdirSync(dirPath)
681
- .map(file => {
682
- const filePath = require('node:path').join(dirPath, file)
683
- const stats = require('node:fs').statSync(filePath)
684
- return { name: file, path: filePath, mtime: stats.mtimeMs }
685
- })
686
- .sort((a, b) => b.mtime - a.mtime)
687
-
688
- if (files.length > limit) {
689
- files.slice(limit).forEach(f => {
690
- try { require('node:fs').unlinkSync(f.path) } catch { }
691
- })
692
- }
693
- }
694
-
695
- cleanupDir('images', 100) // 图片缓存100个
696
- cleanupDir('media', 20) // 富媒体缓存20个
697
- }
698
-
699
- private logInfo(...args: any[]) {
700
- if (this.config.loggerinfo) {
701
- (this.logger.info as (...args: any[]) => void)(...args)
702
- }
703
- }
704
- }
1
+ import { FileManager } from './file-manager'
2
+ import { MessageHandler } from './message-handler'
3
+ import { Context, h, Logger, Universal } from 'koishi'
4
+ import { Config } from './config'
5
+ import { } from '@koishijs/plugin-console'
6
+ import { URL, pathToFileURL, fileURLToPath } from 'node:url'
7
+ import { readFileSync } from 'node:fs'
8
+ import * as mime from 'mime-types'
9
+
10
+ export class ApiHandlers {
11
+ private logger: Logger
12
+ // 当前临时缓存的视频文件路径
13
+ private currentTempVideo: string | null = null
14
+
15
+ constructor(
16
+ private ctx: Context,
17
+ private config: Config,
18
+ private fileManager: FileManager,
19
+ private messageHandler: MessageHandler
20
+ ) {
21
+ this.logger = ctx.logger('chat-patch')
22
+ }
23
+
24
+ registerApiHandlers() {
25
+ this.ctx.console.addListener('clear-all-indexeddb-data' as any, async () => {
26
+ try {
27
+ this.logInfo('收到清空 IndexedDB 数据请求')
28
+ // 这个 API 主要用于前端调用,后端不直接操作 IndexedDB
29
+ return { success: true, message: '可以清空 IndexedDB' }
30
+ } catch (error: any) {
31
+ this.logger.error('清空 IndexedDB 数据失败:', error)
32
+ return { success: false, error: error?.message || String(error) }
33
+ }
34
+ })
35
+
36
+ // 获取所有聊天数据的 API
37
+ this.ctx.console.addListener('get-chat-data' as any, async () => {
38
+ try {
39
+ // 优化性能:只读取基础信息,不读取庞大的消息体
40
+ const data = this.fileManager.readChatDataFromFile()
41
+
42
+ this.logInfo('获取基础聊天数据')
43
+
44
+ return {
45
+ success: true,
46
+ data: {
47
+ bots: data.bots || {},
48
+ channels: data.channels || {},
49
+ pinnedBots: data.pinnedBots || [],
50
+ pinnedChannels: data.pinnedChannels || [],
51
+ // 不返回 messages,由前端按需拉取
52
+ messages: {}
53
+ }
54
+ }
55
+ } catch (error: any) {
56
+ this.logger.error('获取聊天数据失败:', error)
57
+ return { success: false, error: error?.message || String(error) }
58
+ }
59
+ })
60
+
61
+ // 获取历史消息的 API
62
+ this.ctx.console.addListener('get-history-messages' as any, async (requestData: {
63
+ selfId: string
64
+ channelId: string
65
+ limit?: number
66
+ offset?: number
67
+ }) => {
68
+ try {
69
+ const data = this.fileManager.readChatDataFromFile()
70
+ const channelKey = `${requestData.selfId}:${requestData.channelId}`
71
+ let messages = data.messages[channelKey] || []
72
+
73
+ // 按时间戳降序排列(最新的消息在前面)
74
+ const sortedMessages = messages.sort((a, b) => b.timestamp - a.timestamp)
75
+
76
+ // 如果提供了分页参数,则进行分页处理
77
+ if (requestData.limit !== undefined) {
78
+ const limit = requestData.limit
79
+ const offset = requestData.offset || 0
80
+ messages = sortedMessages.slice(offset, offset + limit)
81
+ // 重新按时间戳升序排列,以保持消息显示顺序
82
+ messages = messages.sort((a, b) => a.timestamp - b.timestamp)
83
+ } else {
84
+ // 默认返回所有消息,按时间戳升序排列
85
+ messages = sortedMessages.sort((a, b) => a.timestamp - b.timestamp)
86
+ }
87
+
88
+ this.logInfo('获取历史消息:', channelKey, '共', messages.length, '条消息')
89
+
90
+ return {
91
+ success: true,
92
+ messages: messages,
93
+ total: sortedMessages.length
94
+ }
95
+ } catch (error: any) {
96
+ this.logger.error('获取历史消息失败:', error)
97
+ return { success: false, error: error?.message || String(error), messages: [], total: 0 }
98
+ }
99
+ })
100
+
101
+ // 获取所有频道消息数量的 API
102
+ this.ctx.console.addListener('get-all-channel-message-counts' as any, async () => {
103
+ try {
104
+ const data = this.fileManager.readChatDataFromFile()
105
+ const counts: Record<string, number> = {}
106
+
107
+ for (const [channelKey, messages] of Object.entries(data.messages)) {
108
+ counts[channelKey] = messages.length
109
+ }
110
+
111
+ this.logInfo('获取所有频道消息数量:', {
112
+ 频道数: Object.keys(counts).length,
113
+ 总消息数: Object.values(counts).reduce((total, count) => total + count, 0)
114
+ })
115
+
116
+ return {
117
+ success: true,
118
+ counts: counts
119
+ }
120
+ } catch (error: any) {
121
+ this.logger.error('获取频道消息数量失败:', error)
122
+ return { success: false, error: error?.message || String(error), counts: {} }
123
+ }
124
+ })
125
+
126
+ // 图片获取 API
127
+ this.ctx.console.addListener('fetch-image' as any, async (data: { url: string }) => {
128
+ try {
129
+ // 检查是否是本地文件路径
130
+ if (this.isFileUrl(data.url)) {
131
+ this.logInfo('处理本地文件请求:', data.url)
132
+ return await this.handleLocalFileRequest(data.url)
133
+ }
134
+
135
+ // 处理网络图片
136
+ const response = await fetch(data.url, {
137
+ headers: {
138
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
139
+ 'Referer': ''
140
+ }
141
+ })
142
+
143
+ if (!response.ok) {
144
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
145
+ }
146
+
147
+ const buffer = await response.arrayBuffer()
148
+ const base64 = Buffer.from(buffer).toString('base64')
149
+ const contentType = response.headers.get('content-type') || 'image/jpeg'
150
+ return {
151
+ success: true,
152
+ base64: base64,
153
+ contentType: contentType,
154
+ dataUrl: `data:${contentType};base64,${base64}`
155
+ }
156
+ } catch (error: any) {
157
+ return { success: false, error: error?.message || String(error) }
158
+ }
159
+ })
160
+
161
+ // 清理频道历史记录的 API
162
+ this.ctx.console.addListener('clear-channel-history' as any, async (data: {
163
+ selfId: string
164
+ channelId: string
165
+ keepCount?: number
166
+ }) => {
167
+ try {
168
+ this.logInfo('收到清理历史记录请求:', data)
169
+
170
+ const chatData = this.fileManager.readChatDataFromFile()
171
+ const channelKey = `${data.selfId}:${data.channelId}`
172
+
173
+ if (!chatData.messages[channelKey]) {
174
+ return { success: true, message: '频道没有历史消息' }
175
+ }
176
+
177
+ const messages = chatData.messages[channelKey]
178
+ const originalCount = messages.length
179
+
180
+ const keepCount = data.keepCount || this.config.keepMessagesOnClear
181
+
182
+ if (keepCount > 0 && originalCount <= keepCount) {
183
+ return { success: true, message: `消息数量(${originalCount})未超过保留数量(${keepCount}),无需清理` }
184
+ }
185
+
186
+ const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
187
+ const keptMessages = keepCount > 0 ? sortedMessages.slice(-keepCount) : []
188
+ const clearedCount = originalCount - keptMessages.length
189
+
190
+ chatData.messages[channelKey] = keptMessages
191
+ this.fileManager.writeChatDataToFile(chatData)
192
+
193
+ this.logInfo(`频道 ${channelKey} 历史记录清理完成:`, {
194
+ 原始消息数: originalCount,
195
+ 保留消息数: keptMessages.length,
196
+ 清理消息数: clearedCount
197
+ })
198
+
199
+ return {
200
+ success: true,
201
+ message: `成功清理 ${clearedCount} 条历史消息,保留最新 ${keptMessages.length} 条`,
202
+ clearedCount: clearedCount,
203
+ keptCount: keptMessages.length
204
+ }
205
+ } catch (error: any) {
206
+ this.logger.error('清理频道历史记录失败:', error)
207
+ return { success: false, error: error?.message || String(error) }
208
+ }
209
+ })
210
+
211
+ // 发送消息的 API
212
+ this.ctx.console.addListener('send-message' as any, async (data: {
213
+ selfId: string
214
+ channelId: string
215
+ content: string
216
+ images?: Array<{
217
+ tempId: string
218
+ filename: string
219
+ }>
220
+ }) => {
221
+ try {
222
+ this.logInfo('收到发送消息请求:', data)
223
+
224
+ // 优化机器人查找逻辑
225
+ const bot = this.ctx.bots.find((bot: any) => bot.selfId === data.selfId || bot.user?.id === data.selfId)
226
+ if (!bot) {
227
+ this.logger.error('未找到机器人:', data.selfId, '当前可用机器人:', this.ctx.bots.map((b: any) => b.selfId))
228
+ return { success: false, error: `未找到机器人 ${data.selfId},请检查机器人是否在线` }
229
+ }
230
+
231
+ // 检查机器人状态
232
+ if (bot.status !== Universal.Status.ONLINE) {
233
+ this.logger.error('机器人离线:', data.selfId, '状态:', bot.status)
234
+ return { success: false, error: `机器人 ${data.selfId} 当前离线` }
235
+ }
236
+
237
+ let messageContent = data.content
238
+
239
+ // 如果有图片,添加图片元素
240
+ if (data.images && data.images.length > 0) {
241
+ const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
242
+
243
+ for (const image of data.images) {
244
+ const files = require('fs').readdirSync(tempDir).filter((file: string) =>
245
+ file.includes(`temp_${image.tempId}`)
246
+ )
247
+
248
+ if (files.length > 0) {
249
+ const imagePath = `${tempDir}/${files[0]}`
250
+ // 使用 pathToFileURL 创建正确的文件 URL
251
+ const fileUrl = this.createFileUrl(imagePath)
252
+ messageContent += h.image(fileUrl).toString()
253
+ this.logInfo('添加图片到消息:', { imagePath, fileUrl })
254
+ }
255
+ }
256
+ }
257
+
258
+ const parsedContent = h.parse(messageContent)
259
+
260
+ // 在发送消息前,通知 MessageHandler 正确的 channelId
261
+ this.messageHandler.setCorrectChannelId(data.selfId, data.channelId)
262
+
263
+ const result = await bot.sendMessage(data.channelId, parsedContent)
264
+ this.logInfo('消息发送成功:', result)
265
+
266
+ const messageId = Array.isArray(result) ? result[0] : result;
267
+
268
+ // 发送成功后,更新本地存储中的虚拟消息为真实 ID
269
+ if (messageId) {
270
+ const chatData = this.fileManager.readChatDataFromFile()
271
+ const channelKey = `${data.selfId}:${data.channelId}`
272
+ const messages = chatData.messages[channelKey] || []
273
+ // 找到最近的一条正在发送的机器人消息
274
+ const msg = [...messages].reverse().find(m => m.type === 'bot' && m.sending)
275
+ if (msg) {
276
+ msg.realId = messageId;
277
+ msg.sending = false;
278
+ this.fileManager.writeChatDataToFile(chatData)
279
+
280
+ // 广播更新事件给前端
281
+ this.ctx.console.broadcast('bot-message-updated', {
282
+ channelKey,
283
+ tempId: msg.id,
284
+ realId: messageId
285
+ })
286
+ }
287
+ }
288
+
289
+ return {
290
+ success: !!messageId,
291
+ messageId: messageId,
292
+ tempImageIds: data.images?.map(img => img.tempId) || []
293
+ }
294
+ } catch (error: any) {
295
+ this.logger.error('发送消息失败:', error)
296
+ return { success: false, error: error?.message || String(error) }
297
+ }
298
+ })
299
+
300
+ // 清理临时图片的 API(由前端在消息发送成功后调用)
301
+ this.ctx.console.addListener('cleanup-temp-images' as any, async (data: {
302
+ tempImageIds: string[]
303
+ }) => {
304
+ try {
305
+ this.logInfo('收到清理临时图片请求:', data.tempImageIds)
306
+
307
+ // 不立即删除,只是记录日志,让定时任务处理清理
308
+ this.logInfo('临时图片将由定时任务清理,保持文件可用性')
309
+
310
+ return { success: true, cleanedCount: 0 }
311
+ } catch (error: any) {
312
+ this.logger.error('清理临时图片失败:', error)
313
+ return { success: false, error: error?.message || String(error) }
314
+ }
315
+ })
316
+
317
+ // 删除机器人所有数据的 API
318
+ this.ctx.console.addListener('delete-bot-data' as any, async (data: {
319
+ selfId: string
320
+ }) => {
321
+ try {
322
+ this.logInfo('收到删除机器人数据请求:', data)
323
+
324
+ const chatData = this.fileManager.readChatDataFromFile()
325
+ let deletedChannels = 0
326
+ let deletedMessages = 0
327
+
328
+ if (chatData.channels[data.selfId]) {
329
+ deletedChannels = Object.keys(chatData.channels[data.selfId]).length
330
+ delete chatData.channels[data.selfId]
331
+ }
332
+
333
+ const channelsToDelete = Object.keys(chatData.messages).filter(key => key.startsWith(`${data.selfId}:`))
334
+ for (const channelKey of channelsToDelete) {
335
+ deletedMessages += chatData.messages[channelKey].length
336
+ delete chatData.messages[channelKey]
337
+ }
338
+
339
+ delete chatData.bots[data.selfId]
340
+ this.fileManager.writeChatDataToFile(chatData)
341
+
342
+ this.logInfo(`机器人 ${data.selfId} 数据删除完成:`, {
343
+ 删除频道数: deletedChannels,
344
+ 删除消息数: deletedMessages
345
+ })
346
+
347
+ return {
348
+ success: true,
349
+ message: `成功删除机器人数据:${deletedChannels} 个频道,${deletedMessages} 条消息`,
350
+ deletedChannels,
351
+ deletedMessages
352
+ }
353
+ } catch (error: any) {
354
+ this.logger.error('删除机器人数据失败:', error)
355
+ return { success: false, error: error?.message || String(error) }
356
+ }
357
+ })
358
+
359
+ // 删除频道所有数据的 API
360
+ this.ctx.console.addListener('delete-channel-data' as any, async (data: {
361
+ selfId: string
362
+ channelId: string
363
+ }) => {
364
+ try {
365
+ this.logInfo('收到删除频道数据请求:', data)
366
+
367
+ const chatData = this.fileManager.readChatDataFromFile()
368
+ const channelKey = `${data.selfId}:${data.channelId}`
369
+ let deletedMessages = 0
370
+
371
+ if (chatData.messages[channelKey]) {
372
+ deletedMessages = chatData.messages[channelKey].length
373
+ delete chatData.messages[channelKey]
374
+ }
375
+
376
+ if (chatData.channels[data.selfId] && chatData.channels[data.selfId][data.channelId]) {
377
+ delete chatData.channels[data.selfId][data.channelId]
378
+ }
379
+
380
+ this.fileManager.writeChatDataToFile(chatData)
381
+
382
+ this.logInfo(`频道 ${channelKey} 数据删除完成:`, {
383
+ 删除消息数: deletedMessages
384
+ })
385
+
386
+ return {
387
+ success: true,
388
+ message: `成功删除频道数据:${deletedMessages} 条消息`,
389
+ deletedMessages
390
+ }
391
+ } catch (error: any) {
392
+ this.logger.error('删除频道数据失败:', error)
393
+ return { success: false, error: error?.message || String(error) }
394
+ }
395
+ })
396
+
397
+ // 设置置顶机器人列表的 API
398
+ this.ctx.console.addListener('set-pinned-bots' as any, async (data: {
399
+ pinnedBots: string[]
400
+ }) => {
401
+ try {
402
+ this.logInfo('收到设置置顶机器人请求:', data.pinnedBots)
403
+ const chatData = this.fileManager.readChatDataFromFile()
404
+ chatData.pinnedBots = data.pinnedBots
405
+ this.fileManager.writeChatDataToFile(chatData)
406
+ return { success: true }
407
+ } catch (error: any) {
408
+ this.logger.error('设置置顶机器人失败:', error)
409
+ return { success: false, error: error?.message || String(error) }
410
+ }
411
+ })
412
+
413
+ // 设置置顶频道列表的 API
414
+ this.ctx.console.addListener('set-pinned-channels' as any, async (data: {
415
+ pinnedChannels: string[]
416
+ }) => {
417
+ try {
418
+ this.logInfo('收到设置置顶频道请求:', data.pinnedChannels)
419
+ const chatData = this.fileManager.readChatDataFromFile()
420
+ chatData.pinnedChannels = data.pinnedChannels
421
+ this.fileManager.writeChatDataToFile(chatData)
422
+ return { success: true }
423
+ } catch (error: any) {
424
+ this.logger.error('设置置顶频道失败:', error)
425
+ return { success: false, error: error?.message || String(error) }
426
+ }
427
+ })
428
+
429
+ // 上传图片的 API
430
+ this.ctx.console.addListener('upload-image' as any, async (data: {
431
+ file: string // base64 encoded image
432
+ filename: string
433
+ mimeType: string
434
+ isGif?: boolean // 是否为GIF图片
435
+ }) => {
436
+ try {
437
+ this.logInfo('收到图片上传请求:', { filename: data.filename, mimeType: data.mimeType, isGif: data.isGif })
438
+
439
+ // 将base64转换为Buffer
440
+ const base64Data = data.file.replace(/^data:image\/\w+;base64,/, '')
441
+ const buffer = Buffer.from(base64Data, 'base64')
442
+
443
+ // 生成临时文件路径,保持原始扩展名以保持GIF格式
444
+ const tempId = Date.now() + '_' + Math.random().toString(36).substring(2, 11)
445
+ const extension = data.filename.split('.').pop()?.toLowerCase() || (data.isGif ? 'gif' : 'jpg')
446
+ const tempFilename = `temp_${tempId}.${extension}`
447
+
448
+ // 保存到临时目录
449
+ const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
450
+ if (!require('fs').existsSync(tempDir)) {
451
+ require('fs').mkdirSync(tempDir, { recursive: true })
452
+ }
453
+
454
+ const tempPath = `${tempDir}/${tempFilename}`
455
+
456
+ // 对于GIF文件,直接保存原始数据以保持动画
457
+ require('fs').writeFileSync(tempPath, buffer)
458
+
459
+ this.logInfo('图片上传成功:', { tempPath, size: buffer.length, isGif: data.isGif })
460
+
461
+ return {
462
+ success: true,
463
+ tempId: tempId,
464
+ tempPath: tempPath,
465
+ filename: data.filename,
466
+ size: buffer.length,
467
+ isGif: data.isGif
468
+ }
469
+ } catch (error: any) {
470
+ this.logger.error('图片上传失败:', error)
471
+ return { success: false, error: error?.message || String(error) }
472
+ }
473
+ })
474
+
475
+ // 删除临时图片的 API
476
+ this.ctx.console.addListener('delete-temp-image' as any, async (data: {
477
+ tempId: string
478
+ }) => {
479
+ try {
480
+ const tempDir = this.ctx.baseDir + '/data/chat-patch/temp'
481
+ const files = require('fs').readdirSync(tempDir).filter((file: string) =>
482
+ file.includes(`temp_${data.tempId}`)
483
+ )
484
+
485
+ for (const file of files) {
486
+ const filePath = `${tempDir}/${file}`
487
+ if (require('fs').existsSync(filePath)) {
488
+ require('fs').unlinkSync(filePath)
489
+ this.logInfo('删除临时图片:', filePath)
490
+ }
491
+ }
492
+
493
+ return { success: true }
494
+ } catch (error: any) {
495
+ this.logger.error('删除临时图片失败:', error)
496
+ return { success: false, error: error?.message || String(error) }
497
+ }
498
+ })
499
+
500
+ // 定时清理过期临时文件(备用机制)
501
+ this.setupTempFileCleanup()
502
+
503
+ // 获取插件配置的 API
504
+ this.ctx.console.addListener('get-plugin-config' as any, async () => {
505
+ try {
506
+ return {
507
+ success: true,
508
+ config: {
509
+ maxMessagesPerChannel: this.config.maxMessagesPerChannel,
510
+ keepMessagesOnClear: this.config.keepMessagesOnClear,
511
+ maxPersistImages: this.config.maxPersistImages,
512
+ loggerinfo: this.config.loggerinfo,
513
+ blockedPlatforms: this.config.blockedPlatforms || [],
514
+ clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
515
+ }
516
+ }
517
+ } catch (error: any) {
518
+ this.logger.error('获取插件配置失败:', error)
519
+ return { success: false, error: error?.message || String(error) }
520
+ }
521
+ })
522
+
523
+ // 获取用户信息 API
524
+ this.ctx.console.addListener('get-user-info' as any, async (data: { selfId: string, userId: string, guildId?: string }) => {
525
+ try {
526
+ const bot = this.ctx.bots.find(b => b.selfId === data.selfId)
527
+ if (!bot) return { success: false, error: '机器人不存在' }
528
+
529
+ // 检查平台是否支持 getUser 方法
530
+ if (!bot.getUser || typeof bot.getUser !== 'function') {
531
+ return { success: false, error: '此平台不支持查看用户信息' }
532
+ }
533
+
534
+ const user = await bot.getUser(data.userId, data.guildId)
535
+
536
+ if (user) {
537
+ // 缓存头像到本地
538
+ if (user.avatar) {
539
+ user.avatar = await this.messageHandler.downloadAndCacheMedia(user.avatar, 'avatar')
540
+ }
541
+
542
+ const chatData = this.fileManager.readChatDataFromFile()
543
+ let changed = false
544
+
545
+ // 1. 如果是私聊,更新频道名 - 支持多种私聊ID格式
546
+ const botChannels = chatData.channels[data.selfId] || {}
547
+
548
+ // 查找所有可能的私聊频道ID格式
549
+ const possibleChannelIds = [
550
+ data.userId,
551
+ `private:${data.userId}`,
552
+ `direct:${data.userId}`
553
+ ]
554
+
555
+ for (const channelId of possibleChannelIds) {
556
+ const channel = botChannels[channelId]
557
+ if (channel && channel.isDirect) {
558
+ const newName = `私聊(${user.name})`
559
+ if (channel.name !== newName) {
560
+ channel.name = newName
561
+ changed = true
562
+ this.logInfo('更新私聊频道名称:', { channelId, oldName: channel.name, newName })
563
+ }
564
+ }
565
+ }
566
+
567
+ // 2. 更新该用户在所有历史消息中的头像和昵称(持久化缓存)
568
+ const channelKeyPrefix = `${data.selfId}:`
569
+ for (const [key, messages] of Object.entries(chatData.messages)) {
570
+ if (key.startsWith(channelKeyPrefix)) {
571
+ messages.forEach(msg => {
572
+ if (msg.userId === data.userId) {
573
+ if (user.name && msg.username !== user.name) {
574
+ msg.username = user.name
575
+ changed = true
576
+ }
577
+ if (user.avatar && msg.avatar !== user.avatar) {
578
+ msg.avatar = user.avatar
579
+ changed = true
580
+ }
581
+ }
582
+ });
583
+ }
584
+ }
585
+
586
+ if (changed) {
587
+ this.fileManager.writeChatDataToFile(chatData)
588
+ // 广播更新事件,让前端实时刷新
589
+ this.ctx.console.broadcast('chat-data-updated', {})
590
+ }
591
+ }
592
+
593
+ return { success: true, data: user }
594
+ } catch (error: any) {
595
+ return { success: false, error: error?.message || '获取用户信息失败' }
596
+ }
597
+ })
598
+
599
+ // 调试API:获取原始文件数据
600
+ this.ctx.console.addListener('debug-get-raw-data' as any, async () => {
601
+ try {
602
+ const data = this.fileManager.readChatDataFromFile()
603
+ return {
604
+ success: true,
605
+ data: data
606
+ }
607
+ } catch (error: any) {
608
+ this.logger.error('获取原始数据失败:', error)
609
+ return { success: false, error: error?.message || String(error) }
610
+ }
611
+ })
612
+
613
+ // 按需加载视频的临时缓存 API - 返回 base64 供前端转换为 blob
614
+ this.ctx.console.addListener('fetch-video-temp' as any, async (data: { url: string }) => {
615
+ try {
616
+ this.logInfo('收到视频临时加载请求:', data.url)
617
+
618
+ // 检查是否是本地文件
619
+ if (this.isFileUrl(data.url)) {
620
+ const result = await this.handleLocalFileRequest(data.url)
621
+ return result
622
+ }
623
+
624
+ // 下载视频并返回 base64
625
+ const response = await fetch(data.url, {
626
+ headers: {
627
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
628
+ 'Referer': ''
629
+ }
630
+ })
631
+
632
+ if (!response.ok) {
633
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
634
+ }
635
+
636
+ const buffer = await response.arrayBuffer()
637
+ const base64 = Buffer.from(buffer).toString('base64')
638
+
639
+ // 尝试从 Content-Type 获取 MIME 类型
640
+ const contentType = response.headers.get('content-type') || 'video/mp4'
641
+
642
+ this.logInfo('视频下载成功:', { size: buffer.byteLength, contentType })
643
+
644
+ return {
645
+ success: true,
646
+ base64: base64,
647
+ contentType: contentType,
648
+ dataUrl: `data:${contentType};base64,${base64}`,
649
+ size: buffer.byteLength
650
+ }
651
+ } catch (error: any) {
652
+ this.logger.error('视频临时加载失败:', error)
653
+ return { success: false, error: error?.message || String(error) }
654
+ }
655
+ })
656
+ }
657
+
658
+ // 检查是否为文件 URL
659
+ private isFileUrl(url: string): boolean {
660
+ try {
661
+ const parsedUrl = new URL(url)
662
+ return parsedUrl.protocol === 'file:'
663
+ } catch {
664
+ return false
665
+ }
666
+ }
667
+
668
+ // 创建文件 URL
669
+ private createFileUrl(filePath: string): string {
670
+ try {
671
+ return pathToFileURL(filePath).href
672
+ } catch (error) {
673
+ this.logger.error('创建文件URL失败:', { filePath, error })
674
+ // 回退到简单的字符串拼接
675
+ return `file://${filePath}`
676
+ }
677
+ }
678
+
679
+ // 处理本地文件请求
680
+ private async handleLocalFileRequest(fileUrl: string) {
681
+ try {
682
+ // 使用 fileURLToPath 转换 file:// URL 为系统路径
683
+ const filePath = fileURLToPath(fileUrl)
684
+
685
+ // 改用 node:fs 直接读取文件,避免 ctx.http.file 可能存在的编码或协议处理问题
686
+ const buffer = readFileSync(filePath)
687
+ const base64 = buffer.toString('base64')
688
+
689
+ // 使用 mime-types 库精确推断 MIME 类型
690
+ const contentType = mime.lookup(filePath) || 'application/octet-stream'
691
+
692
+ this.logInfo('成功读取本地文件:', { fileUrl, filePath, contentType })
693
+
694
+ return {
695
+ success: true,
696
+ base64: base64,
697
+ contentType: contentType,
698
+ dataUrl: `data:${contentType};base64,${base64}`
699
+ }
700
+ } catch (error: any) {
701
+ this.logger.error('读取本地文件失败:', { fileUrl, error: error.message })
702
+ return {
703
+ success: false,
704
+ error: `读取本地文件失败: ${error.message}`
705
+ }
706
+ }
707
+ }
708
+
709
+ // 设置定时清理临时文件
710
+ private setupTempFileCleanup() {
711
+ // 每5分钟执行一次基于数量的清理
712
+ setInterval(() => {
713
+ this.cleanupMediaCache()
714
+ }, 5 * 60 * 1000)
715
+ }
716
+
717
+ // 统一清理媒体缓存
718
+ private async cleanupMediaCache() {
719
+ const baseDir = this.ctx.baseDir + '/data/chat-patch/persist-media'
720
+ if (!require('node:fs').existsSync(baseDir)) return
721
+
722
+ const cleanupDir = (dirName: string, limit: number) => {
723
+ const dirPath = require('node:path').join(baseDir, dirName)
724
+ if (!require('node:fs').existsSync(dirPath)) return
725
+
726
+ const files = require('node:fs').readdirSync(dirPath)
727
+ .map(file => {
728
+ const filePath = require('node:path').join(dirPath, file)
729
+ const stats = require('node:fs').statSync(filePath)
730
+ return { name: file, path: filePath, mtime: stats.mtimeMs }
731
+ })
732
+ .sort((a, b) => b.mtime - a.mtime)
733
+
734
+ if (files.length > limit) {
735
+ files.slice(limit).forEach(f => {
736
+ try { require('node:fs').unlinkSync(f.path) } catch { }
737
+ })
738
+ }
739
+ }
740
+
741
+ cleanupDir('images', 100) // 图片缓存100个
742
+ cleanupDir('media', 20) // 富媒体缓存20个
743
+ }
744
+
745
+ private logInfo(...args: any[]) {
746
+ if (this.config.loggerinfo) {
747
+ (this.logger.info as (...args: any[]) => void)(...args)
748
+ }
749
+ }
750
+ }