koishi-plugin-chat-patch 2.0.1 → 2.1.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/client/index.scss +50 -0
- package/client/vue/chat-logic.ts +471 -11
- package/client/vue/composables/useChatData.ts +4 -2
- package/client/vue/index.vue +279 -101
- package/client/vue/types.ts +2 -0
- package/dist/index.js +2 -2
- package/dist/style.css +1 -1
- package/lib/api-handlers.d.ts +19 -0
- package/lib/config.d.ts +13 -0
- package/lib/index.js +242 -140
- package/lib/message-handler.d.ts +21 -0
- package/lib/types.d.ts +57 -0
- package/package.json +1 -1
- package/src/api-handlers.ts +139 -99
- package/src/config.ts +3 -5
- package/src/message-handler.ts +163 -62
- package/src/types.ts +2 -0
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { h } from 'koishi';
|
|
2
|
+
export interface BotInfo {
|
|
3
|
+
selfId: string;
|
|
4
|
+
platform: string;
|
|
5
|
+
username: string;
|
|
6
|
+
avatar?: string;
|
|
7
|
+
status: 'online' | 'offline';
|
|
8
|
+
}
|
|
9
|
+
export interface ChannelInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
type: number | string;
|
|
13
|
+
channelId?: string;
|
|
14
|
+
guildName?: string;
|
|
15
|
+
isDirect?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface QuoteInfo {
|
|
18
|
+
messageId: string;
|
|
19
|
+
id: string;
|
|
20
|
+
content: string;
|
|
21
|
+
elements?: h[];
|
|
22
|
+
user: {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
avatar?: string;
|
|
27
|
+
username: string;
|
|
28
|
+
};
|
|
29
|
+
timestamp: number;
|
|
30
|
+
}
|
|
31
|
+
export interface MessageInfo {
|
|
32
|
+
id: string;
|
|
33
|
+
content: string;
|
|
34
|
+
userId: string;
|
|
35
|
+
username: string;
|
|
36
|
+
avatar?: string;
|
|
37
|
+
timestamp: number;
|
|
38
|
+
channelId: string;
|
|
39
|
+
selfId: string;
|
|
40
|
+
elements?: h[];
|
|
41
|
+
type: 'user' | 'bot';
|
|
42
|
+
guildId?: string;
|
|
43
|
+
guildName?: string;
|
|
44
|
+
platform: string;
|
|
45
|
+
quote?: QuoteInfo;
|
|
46
|
+
isDirect?: boolean;
|
|
47
|
+
sending?: boolean;
|
|
48
|
+
realId?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface ChatData {
|
|
51
|
+
bots: Record<string, BotInfo>;
|
|
52
|
+
channels: Record<string, Record<string, ChannelInfo>>;
|
|
53
|
+
messages: Record<string, MessageInfo[]>;
|
|
54
|
+
pinnedBots: string[];
|
|
55
|
+
pinnedChannels: string[];
|
|
56
|
+
lastSaveTime?: number;
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-chat-patch",
|
|
3
3
|
"description": "[<ruby>chat-patch<rp>(</rp><rt>点我预览效果</rt><rp>)</rp></ruby>](https://i0.hdslb.com/bfs/openplatform/71074dfc9e5256fc3333d8bd8478bec1af874046.png) 视奸小插件((bushi( (低性能警告)。手机端适配。灵感来自 chat 插件。",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.1",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
package/src/api-handlers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FileManager } from './file-manager'
|
|
2
2
|
import { MessageHandler } from './message-handler'
|
|
3
|
-
import { Context, h, Logger } from 'koishi'
|
|
3
|
+
import { Context, h, Logger, Universal } from 'koishi'
|
|
4
4
|
import { Config } from './config'
|
|
5
5
|
import { } from '@koishijs/plugin-console'
|
|
6
6
|
import { URL, pathToFileURL, fileURLToPath } from 'node:url'
|
|
@@ -34,23 +34,20 @@ export class ApiHandlers {
|
|
|
34
34
|
// 获取所有聊天数据的 API
|
|
35
35
|
this.ctx.console.addListener('get-chat-data' as any, async () => {
|
|
36
36
|
try {
|
|
37
|
+
// 优化性能:只读取基础信息,不读取庞大的消息体
|
|
37
38
|
const data = this.fileManager.readChatDataFromFile()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.logInfo('获取聊天数据:', {
|
|
41
|
-
机器人数量: Object.keys(cleanedData.bots).length,
|
|
42
|
-
频道数量: Object.keys(cleanedData.channels).reduce((total, botId) =>
|
|
43
|
-
total + Object.keys(cleanedData.channels[botId] || {}).length, 0),
|
|
44
|
-
消息频道数: Object.keys(cleanedData.messages).length,
|
|
45
|
-
总消息数: Object.values(cleanedData.messages).reduce((total, msgs) => total + msgs.length, 0)
|
|
46
|
-
})
|
|
39
|
+
|
|
40
|
+
this.logInfo('获取基础聊天数据')
|
|
47
41
|
|
|
48
42
|
return {
|
|
49
43
|
success: true,
|
|
50
44
|
data: {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
bots: data.bots || {},
|
|
46
|
+
channels: data.channels || {},
|
|
47
|
+
pinnedBots: data.pinnedBots || [],
|
|
48
|
+
pinnedChannels: data.pinnedChannels || [],
|
|
49
|
+
// 不返回 messages,由前端按需拉取
|
|
50
|
+
messages: {}
|
|
54
51
|
}
|
|
55
52
|
}
|
|
56
53
|
} catch (error: any) {
|
|
@@ -222,10 +219,17 @@ export class ApiHandlers {
|
|
|
222
219
|
try {
|
|
223
220
|
this.logInfo('收到发送消息请求:', data)
|
|
224
221
|
|
|
225
|
-
|
|
222
|
+
// 优化机器人查找逻辑
|
|
223
|
+
const bot = this.ctx.bots.find((bot: any) => bot.selfId === data.selfId || bot.user?.id === data.selfId)
|
|
226
224
|
if (!bot) {
|
|
227
|
-
this.logger.error('未找到机器人:', data.selfId)
|
|
228
|
-
return { success: false, error:
|
|
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} 当前离线` }
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
let messageContent = data.content
|
|
@@ -257,32 +261,31 @@ export class ApiHandlers {
|
|
|
257
261
|
const result = await bot.sendMessage(data.channelId, parsedContent)
|
|
258
262
|
this.logInfo('消息发送成功:', result)
|
|
259
263
|
|
|
260
|
-
// 广播机器人消息发送成功事件,包含真实的 messageId
|
|
261
264
|
const messageId = Array.isArray(result) ? result[0] : result;
|
|
265
|
+
|
|
266
|
+
// 发送成功后,更新本地存储中的虚拟消息为真实 ID
|
|
262
267
|
if (messageId) {
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
this.ctx.console.broadcast('bot-message-sent-event', botMessageEvent);
|
|
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
|
+
}
|
|
282
285
|
}
|
|
283
286
|
|
|
284
287
|
return {
|
|
285
|
-
success:
|
|
288
|
+
success: !!messageId,
|
|
286
289
|
messageId: messageId,
|
|
287
290
|
tempImageIds: data.images?.map(img => img.tempId) || []
|
|
288
291
|
}
|
|
@@ -503,7 +506,7 @@ export class ApiHandlers {
|
|
|
503
506
|
config: {
|
|
504
507
|
maxMessagesPerChannel: this.config.maxMessagesPerChannel,
|
|
505
508
|
keepMessagesOnClear: this.config.keepMessagesOnClear,
|
|
506
|
-
|
|
509
|
+
maxPersistImages: this.config.maxPersistImages,
|
|
507
510
|
loggerinfo: this.config.loggerinfo,
|
|
508
511
|
blockedPlatforms: this.config.blockedPlatforms || [],
|
|
509
512
|
clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
|
|
@@ -515,6 +518,82 @@ export class ApiHandlers {
|
|
|
515
518
|
}
|
|
516
519
|
})
|
|
517
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
|
+
|
|
518
597
|
// 调试API:获取原始文件数据
|
|
519
598
|
this.ctx.console.addListener('debug-get-raw-data' as any, async () => {
|
|
520
599
|
try {
|
|
@@ -585,75 +664,36 @@ export class ApiHandlers {
|
|
|
585
664
|
private setupTempFileCleanup() {
|
|
586
665
|
// 每5分钟执行一次基于数量的清理
|
|
587
666
|
setInterval(() => {
|
|
588
|
-
this.
|
|
589
|
-
}, 5 * 60 * 1000)
|
|
667
|
+
this.cleanupMediaCache()
|
|
668
|
+
}, 5 * 60 * 1000)
|
|
590
669
|
}
|
|
591
670
|
|
|
592
|
-
//
|
|
593
|
-
private async
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (!require('fs').existsSync(tempDir)) {
|
|
597
|
-
return
|
|
598
|
-
}
|
|
671
|
+
// 统一清理媒体缓存
|
|
672
|
+
private async cleanupMediaCache() {
|
|
673
|
+
const baseDir = this.ctx.baseDir + '/data/chat-patch/persist-media'
|
|
674
|
+
if (!require('node:fs').existsSync(baseDir)) return
|
|
599
675
|
|
|
600
|
-
|
|
601
|
-
const
|
|
676
|
+
const cleanupDir = (dirName: string, limit: number) => {
|
|
677
|
+
const dirPath = require('node:path').join(baseDir, dirName)
|
|
678
|
+
if (!require('node:fs').existsSync(dirPath)) return
|
|
602
679
|
|
|
603
|
-
const files = require('fs').readdirSync(
|
|
604
|
-
.
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
const stats = require('fs').statSync(filePath)
|
|
609
|
-
const fileAge = now - stats.mtime.getTime()
|
|
610
|
-
return {
|
|
611
|
-
name: file,
|
|
612
|
-
path: filePath,
|
|
613
|
-
mtime: stats.mtime.getTime(),
|
|
614
|
-
age: fileAge,
|
|
615
|
-
protected: fileAge < protectionTime // 是否在保护期内
|
|
616
|
-
}
|
|
617
|
-
} catch (error) {
|
|
618
|
-
return null
|
|
619
|
-
}
|
|
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 }
|
|
620
685
|
})
|
|
621
|
-
.
|
|
622
|
-
.sort((a: any, b: any) => b.mtime - a.mtime) // 按修改时间降序排列(最新的在前)
|
|
623
|
-
|
|
624
|
-
const keepCount = this.config.keepTempImages
|
|
625
|
-
let cleanedCount = 0
|
|
626
|
-
let protectedCount = 0
|
|
686
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
627
687
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
protectedCount = protectedFiles.length
|
|
633
|
-
|
|
634
|
-
// 如果可删除的文件数量超过了保留数量,则删除多余的
|
|
635
|
-
if (deletableFiles.length > keepCount) {
|
|
636
|
-
const filesToDelete = deletableFiles.slice(keepCount) // 保留前N个,删除其余的
|
|
637
|
-
|
|
638
|
-
for (const file of filesToDelete) {
|
|
639
|
-
try {
|
|
640
|
-
if (require('fs').existsSync(file.path)) {
|
|
641
|
-
require('fs').unlinkSync(file.path)
|
|
642
|
-
cleanedCount++
|
|
643
|
-
this.logInfo('清理多余临时图片:', file.path)
|
|
644
|
-
}
|
|
645
|
-
} catch (fileError) {
|
|
646
|
-
this.logger.warn('删除临时文件失败:', { file: file.name, error: fileError })
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (cleanedCount > 0 || protectedCount > 0) {
|
|
652
|
-
this.logInfo(`基于数量清理完成,保留最新 ${keepCount} 张图片,清理了 ${cleanedCount} 张多余图片,保护了 ${protectedCount} 张新上传图片`)
|
|
688
|
+
if (files.length > limit) {
|
|
689
|
+
files.slice(limit).forEach(f => {
|
|
690
|
+
try { require('node:fs').unlinkSync(f.path) } catch { }
|
|
691
|
+
})
|
|
653
692
|
}
|
|
654
|
-
} catch (error) {
|
|
655
|
-
this.logger.warn('基于数量清理临时文件失败:', error)
|
|
656
693
|
}
|
|
694
|
+
|
|
695
|
+
cleanupDir('images', 100) // 图片缓存100个
|
|
696
|
+
cleanupDir('media', 20) // 富媒体缓存20个
|
|
657
697
|
}
|
|
658
698
|
|
|
659
699
|
private logInfo(...args: any[]) {
|
package/src/config.ts
CHANGED
|
@@ -5,7 +5,6 @@ export interface Config {
|
|
|
5
5
|
clearIndexedDBOnStart: boolean
|
|
6
6
|
maxMessagesPerChannel: number
|
|
7
7
|
keepMessagesOnClear: number
|
|
8
|
-
keepTempImages: number
|
|
9
8
|
maxPersistImages: number
|
|
10
9
|
blockedPlatforms: Array<{
|
|
11
10
|
platformName: string
|
|
@@ -15,10 +14,9 @@ export interface Config {
|
|
|
15
14
|
|
|
16
15
|
export const Config: Schema<Config> = Schema.intersect([
|
|
17
16
|
Schema.object({
|
|
18
|
-
maxMessagesPerChannel: Schema.number().default(500).description('每个群组最大保存消息数量').min(50).max(1500),
|
|
19
|
-
keepMessagesOnClear: Schema.number().default(50).description('手动清理历史记录时保留的消息数量').min(0).max(1000),
|
|
20
|
-
|
|
21
|
-
maxPersistImages: Schema.number().default(100).description('持久化存储的机器人发送图片数量').min(10).max(500),
|
|
17
|
+
maxMessagesPerChannel: Schema.number().default(500).description('每个群组最大保存消息数量').min(50).max(1500).step(1),
|
|
18
|
+
keepMessagesOnClear: Schema.number().default(50).description('手动清理历史记录时保留的消息数量').min(0).max(1000).step(1),
|
|
19
|
+
maxPersistImages: Schema.number().default(100).description('持久化存储的图片缓存数量').min(10).max(500).step(1),
|
|
22
20
|
blockedPlatforms: Schema.array(Schema.object({
|
|
23
21
|
platformName: Schema.string().description('平台名称或关键词'),
|
|
24
22
|
exactMatch: Schema.boolean().default(false).description('完全匹配?如果关闭,包含关键词即屏蔽').default(true)
|