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.
- package/client/vue/chat-logic.ts +683 -678
- package/client/vue/composables/useVideoCache.ts +130 -0
- package/client/vue/index.vue +658 -612
- package/dist/index.js +2 -2
- package/lib/api-handlers.d.ts +1 -0
- package/lib/index.js +91 -20
- package/lib/utils.d.ts +13 -0
- package/package.json +52 -49
- package/src/api-handlers.ts +750 -704
- package/src/index.ts +148 -125
- package/src/message-handler.ts +413 -407
- package/src/utils.ts +163 -144
package/src/utils.ts
CHANGED
|
@@ -1,144 +1,163 @@
|
|
|
1
|
-
import { Config } from './config'
|
|
2
|
-
import { Context } from 'koishi'
|
|
3
|
-
import { writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
6
|
-
import { pathToFileURL } from 'node:url'
|
|
7
|
-
|
|
8
|
-
export class Utils {
|
|
9
|
-
constructor(private config: Config, private ctx?: Context) { }
|
|
10
|
-
|
|
11
|
-
// 检查平台是否被屏蔽
|
|
12
|
-
isPlatformBlocked(platform: string): boolean {
|
|
13
|
-
if (!this.config.blockedPlatforms || this.config.blockedPlatforms.length === 0) {
|
|
14
|
-
return false
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
for (const blockedPlatform of this.config.blockedPlatforms) {
|
|
18
|
-
if (blockedPlatform.exactMatch) {
|
|
19
|
-
if (platform === blockedPlatform.platformName) {
|
|
20
|
-
return true
|
|
21
|
-
}
|
|
22
|
-
} else {
|
|
23
|
-
if (platform.includes(blockedPlatform.platformName)) {
|
|
24
|
-
return true
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return false
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// 递归提取所有文本内容的函数
|
|
33
|
-
extractTextContent(elements: any[]): string {
|
|
34
|
-
let text = ''
|
|
35
|
-
for (const element of elements) {
|
|
36
|
-
if (element.type === 'text') {
|
|
37
|
-
text += element.attrs?.content || ''
|
|
38
|
-
} else if (element.type === 'p') {
|
|
39
|
-
if (element.children && element.children.length > 0) {
|
|
40
|
-
text += this.extractTextContent(element.children) + '\n'
|
|
41
|
-
}
|
|
42
|
-
} else if (element.children && element.children.length > 0) {
|
|
43
|
-
text += this.extractTextContent(element.children)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return text
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// 检查字符串是否为base64格式
|
|
50
|
-
private isBase64(str: string): boolean {
|
|
51
|
-
if (!str || typeof str !== 'string') return false
|
|
52
|
-
|
|
53
|
-
// 检查是否以data:开头的base64格式
|
|
54
|
-
if (str.startsWith('data:')) {
|
|
55
|
-
return str.includes('base64,')
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 检查纯base64字符串(长度大于100且符合base64格式)
|
|
59
|
-
if (str.length > 100) {
|
|
60
|
-
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
|
|
61
|
-
return base64Regex.test(str)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return false
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 持久化 base64 图片并返回本地文件 URL
|
|
68
|
-
persistBase64Image(base64Data: string): string {
|
|
69
|
-
if (!this.ctx || !base64Data.startsWith('data:image/')) return base64Data
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const dir = join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-images')
|
|
73
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
74
|
-
|
|
75
|
-
// 生成文件名:时间戳 + 内容哈希
|
|
76
|
-
const hash = createHash('md5').update(base64Data).digest('hex')
|
|
77
|
-
const ext = base64Data.split(';')[0].split('/')[1] || 'png'
|
|
78
|
-
const filename = `${Date.now()}_${hash}.${ext}`
|
|
79
|
-
const filePath = join(dir, filename)
|
|
80
|
-
|
|
81
|
-
// 写入文件
|
|
82
|
-
const base64Content = base64Data.split(',')[1]
|
|
83
|
-
writeFileSync(filePath, Buffer.from(base64Content, 'base64'))
|
|
84
|
-
|
|
85
|
-
// 清理旧图片
|
|
86
|
-
this.cleanupPersistImages(dir)
|
|
87
|
-
|
|
88
|
-
return pathToFileURL(filePath).href
|
|
89
|
-
} catch (e) {
|
|
90
|
-
return base64Data
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private cleanupPersistImages(dir: string) {
|
|
95
|
-
try {
|
|
96
|
-
const files = readdirSync(dir)
|
|
97
|
-
.map(name => ({ name, path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs }))
|
|
98
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
99
|
-
|
|
100
|
-
if (files.length > this.config.maxPersistImages) {
|
|
101
|
-
files.slice(this.config.maxPersistImages).forEach(f => unlinkSync(f.path))
|
|
102
|
-
}
|
|
103
|
-
} catch (e) { }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 清理对象中的base64内容,改为持久化存储
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
1
|
+
import { Config } from './config'
|
|
2
|
+
import { Context } from 'koishi'
|
|
3
|
+
import { writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
export class Utils {
|
|
9
|
+
constructor(private config: Config, private ctx?: Context) { }
|
|
10
|
+
|
|
11
|
+
// 检查平台是否被屏蔽
|
|
12
|
+
isPlatformBlocked(platform: string): boolean {
|
|
13
|
+
if (!this.config.blockedPlatforms || this.config.blockedPlatforms.length === 0) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const blockedPlatform of this.config.blockedPlatforms) {
|
|
18
|
+
if (blockedPlatform.exactMatch) {
|
|
19
|
+
if (platform === blockedPlatform.platformName) {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
if (platform.includes(blockedPlatform.platformName)) {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 递归提取所有文本内容的函数
|
|
33
|
+
extractTextContent(elements: any[]): string {
|
|
34
|
+
let text = ''
|
|
35
|
+
for (const element of elements) {
|
|
36
|
+
if (element.type === 'text') {
|
|
37
|
+
text += element.attrs?.content || ''
|
|
38
|
+
} else if (element.type === 'p') {
|
|
39
|
+
if (element.children && element.children.length > 0) {
|
|
40
|
+
text += this.extractTextContent(element.children) + '\n'
|
|
41
|
+
}
|
|
42
|
+
} else if (element.children && element.children.length > 0) {
|
|
43
|
+
text += this.extractTextContent(element.children)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return text
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 检查字符串是否为base64格式
|
|
50
|
+
private isBase64(str: string): boolean {
|
|
51
|
+
if (!str || typeof str !== 'string') return false
|
|
52
|
+
|
|
53
|
+
// 检查是否以data:开头的base64格式
|
|
54
|
+
if (str.startsWith('data:')) {
|
|
55
|
+
return str.includes('base64,')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 检查纯base64字符串(长度大于100且符合base64格式)
|
|
59
|
+
if (str.length > 100) {
|
|
60
|
+
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
|
|
61
|
+
return base64Regex.test(str)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 持久化 base64 图片并返回本地文件 URL
|
|
68
|
+
persistBase64Image(base64Data: string): string {
|
|
69
|
+
if (!this.ctx || !base64Data.startsWith('data:image/')) return base64Data
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const dir = join(this.ctx.baseDir, 'data', 'chat-patch', 'persist-images')
|
|
73
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
74
|
+
|
|
75
|
+
// 生成文件名:时间戳 + 内容哈希
|
|
76
|
+
const hash = createHash('md5').update(base64Data).digest('hex')
|
|
77
|
+
const ext = base64Data.split(';')[0].split('/')[1] || 'png'
|
|
78
|
+
const filename = `${Date.now()}_${hash}.${ext}`
|
|
79
|
+
const filePath = join(dir, filename)
|
|
80
|
+
|
|
81
|
+
// 写入文件
|
|
82
|
+
const base64Content = base64Data.split(',')[1]
|
|
83
|
+
writeFileSync(filePath, Buffer.from(base64Content, 'base64'))
|
|
84
|
+
|
|
85
|
+
// 清理旧图片
|
|
86
|
+
this.cleanupPersistImages(dir)
|
|
87
|
+
|
|
88
|
+
return pathToFileURL(filePath).href
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return base64Data
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private cleanupPersistImages(dir: string) {
|
|
95
|
+
try {
|
|
96
|
+
const files = readdirSync(dir)
|
|
97
|
+
.map(name => ({ name, path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs }))
|
|
98
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
99
|
+
|
|
100
|
+
if (files.length > this.config.maxPersistImages) {
|
|
101
|
+
files.slice(this.config.maxPersistImages).forEach(f => unlinkSync(f.path))
|
|
102
|
+
}
|
|
103
|
+
} catch (e) { }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 清理对象中的base64内容,改为持久化存储
|
|
107
|
+
// isBotMessage: 是否为机器人发送的消息
|
|
108
|
+
cleanBase64Content(obj: any, isBotMessage: boolean = false): any {
|
|
109
|
+
if (obj === null || obj === undefined) {
|
|
110
|
+
return obj
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof obj === 'string') {
|
|
114
|
+
if (this.isBase64(obj)) {
|
|
115
|
+
// 如果是机器人消息且是非图片的Base64,直接返回占位符
|
|
116
|
+
if (isBotMessage && !obj.startsWith('data:image/')) {
|
|
117
|
+
return '[富媒体内容已省略]'
|
|
118
|
+
}
|
|
119
|
+
return this.persistBase64Image(obj)
|
|
120
|
+
}
|
|
121
|
+
return obj
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(obj)) {
|
|
125
|
+
return obj.map(item => this.cleanBase64Content(item, isBotMessage))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof obj === 'object') {
|
|
129
|
+
const cleaned: any = {}
|
|
130
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
131
|
+
// 检查元素类型,如果是机器人消息的非图片元素,跳过Base64处理
|
|
132
|
+
if (isBotMessage && obj.type && !['text', 'image', 'img'].includes(obj.type)) {
|
|
133
|
+
// 对于video、audio等元素,如果src是Base64,替换为占位符
|
|
134
|
+
if (typeof value === 'string' && (key === 'src' || key === 'url' || key === 'file') && this.isBase64(value)) {
|
|
135
|
+
cleaned[key] = '[富媒体内容已省略]'
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 特别处理常见的base64字段
|
|
141
|
+
if (typeof value === 'string' && (
|
|
142
|
+
key === 'src' ||
|
|
143
|
+
key === 'url' ||
|
|
144
|
+
key === 'file' ||
|
|
145
|
+
key === 'data' ||
|
|
146
|
+
key === 'content'
|
|
147
|
+
) && this.isBase64(value)) {
|
|
148
|
+
// 如果是机器人消息且不是图片Base64,跳过
|
|
149
|
+
if (isBotMessage && !value.startsWith('data:image/')) {
|
|
150
|
+
cleaned[key] = '[富媒体内容已省略]'
|
|
151
|
+
} else {
|
|
152
|
+
cleaned[key] = this.persistBase64Image(value)
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
cleaned[key] = this.cleanBase64Content(value, isBotMessage)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return cleaned
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return obj
|
|
162
|
+
}
|
|
163
|
+
}
|