koishi-plugin-chat-patch 1.3.0 → 2.0.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/index.scss +36 -0
- package/client/index.ts +1 -0
- package/client/vue/chat-logic.ts +148 -3715
- package/client/vue/composables/useChatActions.ts +61 -0
- package/client/vue/composables/useChatData.ts +184 -0
- package/client/vue/composables/useImageCache.ts +140 -0
- package/client/vue/index.vue +399 -327
- package/client/vue/types.ts +71 -0
- package/dist/index.js +5 -27
- package/dist/style.css +1 -1
- package/lib/index.js +68 -36
- package/package.json +14 -5
- package/readme.md +1 -1
- package/src/api-handlers.ts +17 -10
- package/src/config.ts +2 -4
- package/src/index.ts +1 -1
- package/src/message-handler.ts +5 -4
- package/src/utils.ts +48 -4
- package/client/vue/style.css +0 -1997
package/lib/index.js
CHANGED
|
@@ -39,12 +39,17 @@ __export(src_exports, {
|
|
|
39
39
|
usage: () => usage
|
|
40
40
|
});
|
|
41
41
|
module.exports = __toCommonJS(src_exports);
|
|
42
|
-
var
|
|
42
|
+
var import_node_path3 = __toESM(require("node:path"));
|
|
43
43
|
|
|
44
44
|
// src/utils.ts
|
|
45
|
+
var import_node_fs = require("node:fs");
|
|
46
|
+
var import_node_path = require("node:path");
|
|
47
|
+
var import_node_crypto = require("node:crypto");
|
|
48
|
+
var import_node_url = require("node:url");
|
|
45
49
|
var Utils = class {
|
|
46
|
-
constructor(config) {
|
|
50
|
+
constructor(config, ctx) {
|
|
47
51
|
this.config = config;
|
|
52
|
+
this.ctx = ctx;
|
|
48
53
|
}
|
|
49
54
|
static {
|
|
50
55
|
__name(this, "Utils");
|
|
@@ -95,14 +100,41 @@ var Utils = class {
|
|
|
95
100
|
}
|
|
96
101
|
return false;
|
|
97
102
|
}
|
|
98
|
-
//
|
|
103
|
+
// 持久化 base64 图片并返回本地文件 URL
|
|
104
|
+
persistBase64Image(base64Data) {
|
|
105
|
+
if (!this.ctx || !base64Data.startsWith("data:image/")) return base64Data;
|
|
106
|
+
try {
|
|
107
|
+
const dir = (0, import_node_path.join)(this.ctx.baseDir, "data", "chat-patch", "persist-images");
|
|
108
|
+
if (!(0, import_node_fs.existsSync)(dir)) (0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
109
|
+
const hash = (0, import_node_crypto.createHash)("md5").update(base64Data).digest("hex");
|
|
110
|
+
const ext = base64Data.split(";")[0].split("/")[1] || "png";
|
|
111
|
+
const filename = `${Date.now()}_${hash}.${ext}`;
|
|
112
|
+
const filePath = (0, import_node_path.join)(dir, filename);
|
|
113
|
+
const base64Content = base64Data.split(",")[1];
|
|
114
|
+
(0, import_node_fs.writeFileSync)(filePath, Buffer.from(base64Content, "base64"));
|
|
115
|
+
this.cleanupPersistImages(dir);
|
|
116
|
+
return (0, import_node_url.pathToFileURL)(filePath).href;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return base64Data;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
cleanupPersistImages(dir) {
|
|
122
|
+
try {
|
|
123
|
+
const files = (0, import_node_fs.readdirSync)(dir).map((name2) => ({ name: name2, path: (0, import_node_path.join)(dir, name2), mtime: (0, import_node_fs.statSync)((0, import_node_path.join)(dir, name2)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
|
|
124
|
+
if (files.length > this.config.maxPersistImages) {
|
|
125
|
+
files.slice(this.config.maxPersistImages).forEach((f) => (0, import_node_fs.unlinkSync)(f.path));
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// 清理对象中的base64内容,改为持久化存储
|
|
99
131
|
cleanBase64Content(obj) {
|
|
100
132
|
if (obj === null || obj === void 0) {
|
|
101
133
|
return obj;
|
|
102
134
|
}
|
|
103
135
|
if (typeof obj === "string") {
|
|
104
136
|
if (this.isBase64(obj)) {
|
|
105
|
-
return
|
|
137
|
+
return this.persistBase64Image(obj);
|
|
106
138
|
}
|
|
107
139
|
return obj;
|
|
108
140
|
}
|
|
@@ -113,7 +145,7 @@ var Utils = class {
|
|
|
113
145
|
const cleaned = {};
|
|
114
146
|
for (const [key, value] of Object.entries(obj)) {
|
|
115
147
|
if (typeof value === "string" && (key === "src" || key === "url" || key === "file" || key === "data" || key === "content") && this.isBase64(value)) {
|
|
116
|
-
cleaned[key] =
|
|
148
|
+
cleaned[key] = this.persistBase64Image(value);
|
|
117
149
|
} else {
|
|
118
150
|
cleaned[key] = this.cleanBase64Content(value);
|
|
119
151
|
}
|
|
@@ -173,7 +205,7 @@ var MessageHandler = class {
|
|
|
173
205
|
const guild = await session.bot.getGuild(session.guildId);
|
|
174
206
|
guildName = guild?.name || session.channelId;
|
|
175
207
|
}
|
|
176
|
-
if (session.isDirect && session.bot.getUser && typeof session.bot.getUser === "function") {
|
|
208
|
+
if (session.userId && session.isDirect && session.bot.getUser && typeof session.bot.getUser === "function") {
|
|
177
209
|
try {
|
|
178
210
|
const user = await session.bot.getUser(session.userId);
|
|
179
211
|
directUserName = user?.name || session.username || session.userId || "未知用户";
|
|
@@ -198,7 +230,7 @@ var MessageHandler = class {
|
|
|
198
230
|
}
|
|
199
231
|
const channelInfo = {
|
|
200
232
|
id: session.channelId,
|
|
201
|
-
name: session.isDirect ? `私聊(${directUserName})` : `${guildName} (${session.channelId})`,
|
|
233
|
+
name: session.isDirect ? `私聊(${session.username || session.userId || directUserName})` : `${guildName} (${session.channelId})`,
|
|
202
234
|
type: session.type || 0,
|
|
203
235
|
channelId: session.channelId,
|
|
204
236
|
guildName,
|
|
@@ -303,8 +335,8 @@ var MessageHandler = class {
|
|
|
303
335
|
await this.updateBotInfoToFile(session);
|
|
304
336
|
const guildName = await this.updateChannelInfoToFile(session);
|
|
305
337
|
const timestamp = Date.now();
|
|
306
|
-
let content = "";
|
|
307
|
-
if (session.event?.message?.elements) {
|
|
338
|
+
let content = session.content || "";
|
|
339
|
+
if (!content && session.event?.message?.elements) {
|
|
308
340
|
content = this.utils.extractTextContent(session.event.message.elements).trim();
|
|
309
341
|
}
|
|
310
342
|
const messageInfo = {
|
|
@@ -370,13 +402,13 @@ var MessageHandler = class {
|
|
|
370
402
|
};
|
|
371
403
|
|
|
372
404
|
// src/file-manager.ts
|
|
373
|
-
var
|
|
374
|
-
var
|
|
405
|
+
var import_node_path2 = __toESM(require("node:path"));
|
|
406
|
+
var import_node_fs2 = __toESM(require("node:fs"));
|
|
375
407
|
var FileManager = class {
|
|
376
408
|
constructor(ctx, config) {
|
|
377
409
|
this.ctx = ctx;
|
|
378
410
|
this.config = config;
|
|
379
|
-
this.dataFilePath =
|
|
411
|
+
this.dataFilePath = import_node_path2.default.resolve(ctx.baseDir, "data", "chat-patch", "chat-data.json");
|
|
380
412
|
this.logger = ctx.logger("chat-patch");
|
|
381
413
|
this.utils = new Utils(config);
|
|
382
414
|
}
|
|
@@ -389,16 +421,16 @@ var FileManager = class {
|
|
|
389
421
|
utils;
|
|
390
422
|
// 确保目录存在
|
|
391
423
|
ensureDataDir() {
|
|
392
|
-
const dir =
|
|
393
|
-
if (!
|
|
394
|
-
|
|
424
|
+
const dir = import_node_path2.default.dirname(this.dataFilePath);
|
|
425
|
+
if (!import_node_fs2.default.existsSync(dir)) {
|
|
426
|
+
import_node_fs2.default.mkdirSync(dir, { recursive: true });
|
|
395
427
|
}
|
|
396
428
|
}
|
|
397
429
|
// 从JSON文件读取数据
|
|
398
430
|
readChatDataFromFile() {
|
|
399
431
|
try {
|
|
400
|
-
if (
|
|
401
|
-
const jsonData =
|
|
432
|
+
if (import_node_fs2.default.existsSync(this.dataFilePath)) {
|
|
433
|
+
const jsonData = import_node_fs2.default.readFileSync(this.dataFilePath, "utf8");
|
|
402
434
|
const data = JSON.parse(jsonData);
|
|
403
435
|
return {
|
|
404
436
|
bots: data.bots || {},
|
|
@@ -426,7 +458,7 @@ var FileManager = class {
|
|
|
426
458
|
this.ensureDataDir();
|
|
427
459
|
data.lastSaveTime = Date.now();
|
|
428
460
|
const jsonData = JSON.stringify(data, null, 2);
|
|
429
|
-
|
|
461
|
+
import_node_fs2.default.writeFileSync(this.dataFilePath, jsonData, "utf8");
|
|
430
462
|
} catch (error) {
|
|
431
463
|
this.logger.error("写入聊天数据失败:", error);
|
|
432
464
|
}
|
|
@@ -514,7 +546,9 @@ var FileManager = class {
|
|
|
514
546
|
|
|
515
547
|
// src/api-handlers.ts
|
|
516
548
|
var import_koishi = require("koishi");
|
|
517
|
-
var
|
|
549
|
+
var import_node_url2 = require("node:url");
|
|
550
|
+
var import_node_fs3 = require("node:fs");
|
|
551
|
+
var mime = __toESM(require("mime-types"));
|
|
518
552
|
var ApiHandlers = class {
|
|
519
553
|
constructor(ctx, config, fileManager, messageHandler) {
|
|
520
554
|
this.ctx = ctx;
|
|
@@ -706,8 +740,8 @@ var ApiHandlers = class {
|
|
|
706
740
|
messageId,
|
|
707
741
|
content: messageContent,
|
|
708
742
|
userId: data.selfId,
|
|
709
|
-
|
|
710
|
-
|
|
743
|
+
username: bot2?.user?.name || `Bot-${data.selfId}`,
|
|
744
|
+
avatar: bot2?.user?.avatar,
|
|
711
745
|
timestamp: Date.now(),
|
|
712
746
|
guildName: "",
|
|
713
747
|
// 这个信息在前端会补充
|
|
@@ -881,7 +915,6 @@ var ApiHandlers = class {
|
|
|
881
915
|
keepTempImages: this.config.keepTempImages,
|
|
882
916
|
loggerinfo: this.config.loggerinfo,
|
|
883
917
|
blockedPlatforms: this.config.blockedPlatforms || [],
|
|
884
|
-
chatContainerHeight: this.config.chatContainerHeight,
|
|
885
918
|
clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
|
|
886
919
|
}
|
|
887
920
|
};
|
|
@@ -906,7 +939,7 @@ var ApiHandlers = class {
|
|
|
906
939
|
// 检查是否为文件 URL
|
|
907
940
|
isFileUrl(url) {
|
|
908
941
|
try {
|
|
909
|
-
const parsedUrl = new
|
|
942
|
+
const parsedUrl = new import_node_url2.URL(url);
|
|
910
943
|
return parsedUrl.protocol === "file:";
|
|
911
944
|
} catch {
|
|
912
945
|
return false;
|
|
@@ -915,7 +948,7 @@ var ApiHandlers = class {
|
|
|
915
948
|
// 创建文件 URL
|
|
916
949
|
createFileUrl(filePath) {
|
|
917
950
|
try {
|
|
918
|
-
return (0,
|
|
951
|
+
return (0, import_node_url2.pathToFileURL)(filePath).href;
|
|
919
952
|
} catch (error) {
|
|
920
953
|
this.logger.error("创建文件URL失败:", { filePath, error });
|
|
921
954
|
return `file://${filePath}`;
|
|
@@ -924,15 +957,16 @@ var ApiHandlers = class {
|
|
|
924
957
|
// 处理本地文件请求
|
|
925
958
|
async handleLocalFileRequest(fileUrl) {
|
|
926
959
|
try {
|
|
927
|
-
const
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
960
|
+
const filePath = (0, import_node_url2.fileURLToPath)(fileUrl);
|
|
961
|
+
const buffer = (0, import_node_fs3.readFileSync)(filePath);
|
|
962
|
+
const base64 = buffer.toString("base64");
|
|
963
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
964
|
+
this.logInfo("成功读取本地文件:", { fileUrl, filePath, contentType });
|
|
931
965
|
return {
|
|
932
966
|
success: true,
|
|
933
|
-
base64
|
|
967
|
+
base64,
|
|
934
968
|
contentType,
|
|
935
|
-
dataUrl: `data:${contentType};base64,${
|
|
969
|
+
dataUrl: `data:${contentType};base64,${base64}`
|
|
936
970
|
};
|
|
937
971
|
} catch (error) {
|
|
938
972
|
this.logger.error("读取本地文件失败:", { fileUrl, error: error.message });
|
|
@@ -1015,6 +1049,7 @@ var Config = import_koishi2.Schema.intersect([
|
|
|
1015
1049
|
maxMessagesPerChannel: import_koishi2.Schema.number().default(500).description("每个群组最大保存消息数量").min(50).max(1500),
|
|
1016
1050
|
keepMessagesOnClear: import_koishi2.Schema.number().default(50).description("手动清理历史记录时保留的消息数量").min(0).max(1e3),
|
|
1017
1051
|
keepTempImages: import_koishi2.Schema.number().default(50).description("发送消息保留的临时图片数量(最新的N张)").min(10).max(200),
|
|
1052
|
+
maxPersistImages: import_koishi2.Schema.number().default(100).description("持久化存储的机器人发送图片数量").min(10).max(500),
|
|
1018
1053
|
blockedPlatforms: import_koishi2.Schema.array(import_koishi2.Schema.object({
|
|
1019
1054
|
platformName: import_koishi2.Schema.string().description("平台名称或关键词"),
|
|
1020
1055
|
exactMatch: import_koishi2.Schema.boolean().default(false).description("完全匹配?如果关闭,包含关键词即屏蔽").default(true)
|
|
@@ -1035,9 +1070,6 @@ var Config = import_koishi2.Schema.intersect([
|
|
|
1035
1070
|
]
|
|
1036
1071
|
)
|
|
1037
1072
|
}).description("基础设置"),
|
|
1038
|
-
import_koishi2.Schema.object({
|
|
1039
|
-
chatContainerHeight: import_koishi2.Schema.number().default(80).description("手机端使用的视口高度(防止文本输入框被挡住)").min(50).max(100)
|
|
1040
|
-
}).description("进阶设置"),
|
|
1041
1073
|
import_koishi2.Schema.object({
|
|
1042
1074
|
clearIndexedDBOnStart: import_koishi2.Schema.boolean().default(true).description("启动时强制清空IndexedDB缓存(适用于紧急情况,防止浏览器卡死)"),
|
|
1043
1075
|
loggerinfo: import_koishi2.Schema.boolean().default(false).description("日志调试模式").experimental()
|
|
@@ -1066,7 +1098,7 @@ async function apply(ctx, config) {
|
|
|
1066
1098
|
const fileManager = new FileManager(ctx, config);
|
|
1067
1099
|
const messageHandler = new MessageHandler(ctx, config, fileManager);
|
|
1068
1100
|
const apiHandlers = new ApiHandlers(ctx, config, fileManager, messageHandler);
|
|
1069
|
-
const utils = new Utils(config);
|
|
1101
|
+
const utils = new Utils(config, ctx);
|
|
1070
1102
|
const initialData = fileManager.readChatDataFromFile();
|
|
1071
1103
|
const cleanedData = fileManager.cleanExcessMessages(initialData);
|
|
1072
1104
|
const originalCount = Object.values(initialData.messages).reduce((total, msgs) => total + msgs.length, 0);
|
|
@@ -1115,8 +1147,8 @@ async function apply(ctx, config) {
|
|
|
1115
1147
|
});
|
|
1116
1148
|
apiHandlers.registerApiHandlers();
|
|
1117
1149
|
ctx.console.addEntry({
|
|
1118
|
-
dev:
|
|
1119
|
-
prod:
|
|
1150
|
+
dev: import_node_path3.default.resolve(__dirname, "../client/index.ts"),
|
|
1151
|
+
prod: import_node_path3.default.resolve(__dirname, "../dist")
|
|
1120
1152
|
});
|
|
1121
1153
|
}
|
|
1122
1154
|
__name(apply, "apply");
|
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": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"src"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/koishi-shangxue-plugins/koishi-shangxue-apps/",
|
|
15
15
|
"bugs": {
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/koishi-shangxue-plugins/koishi-shangxue-apps/issues"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"koishi",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"聊天室",
|
|
23
23
|
"console"
|
|
24
24
|
],
|
|
25
|
-
"
|
|
25
|
+
"koishi": {
|
|
26
26
|
"service": {
|
|
27
27
|
"required": [
|
|
28
28
|
"console"
|
|
@@ -35,6 +35,15 @@
|
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@koishijs/client": "^5.11.0",
|
|
38
|
-
"
|
|
38
|
+
"@types/mime-types": "^3.0.1",
|
|
39
|
+
"autoprefixer": "^10.4.23",
|
|
40
|
+
"koishi": "^4.16.0",
|
|
41
|
+
"postcss": "^8.5.6",
|
|
42
|
+
"tailwindcss": "^3.4.17"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@element-plus/icons-vue": "^2.3.2",
|
|
46
|
+
"element-plus": "^2.13.0",
|
|
47
|
+
"mime-types": "^3.0.2"
|
|
39
48
|
}
|
|
40
49
|
}
|
package/readme.md
CHANGED
|
@@ -18,7 +18,7 @@ Koishi 控制台聊天插件,允许用户直接在控制台中管理机器人
|
|
|
18
18
|
|
|
19
19
|
欢迎 PR ~
|
|
20
20
|
|
|
21
|
-
PR 方式请参考 -> https://github.com/
|
|
21
|
+
PR 方式请参考 -> <https://github.com/koishi-shangxue-plugins/koishi-shangxue-apps/tree/main?tab=readme-ov-file#%E5%A6%82%E4%BD%95%E5%9C%A8%E9%A1%B9%E7%9B%AE%E6%A8%A1%E6%9D%BF%E4%B8%AD%E5%BC%80%E5%8F%91%E6%AD%A4%E4%BB%93%E5%BA%93>
|
|
22
22
|
|
|
23
23
|
## 许可证
|
|
24
24
|
|
package/src/api-handlers.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { MessageHandler } from './message-handler'
|
|
|
3
3
|
import { Context, h, Logger } from 'koishi'
|
|
4
4
|
import { Config } from './config'
|
|
5
5
|
import { } from '@koishijs/plugin-console'
|
|
6
|
-
import { URL, pathToFileURL } from 'node:url'
|
|
6
|
+
import { URL, pathToFileURL, fileURLToPath } from 'node:url'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import * as mime from 'mime-types'
|
|
7
9
|
|
|
8
10
|
export class ApiHandlers {
|
|
9
11
|
private logger: Logger
|
|
@@ -267,8 +269,8 @@ export class ApiHandlers {
|
|
|
267
269
|
messageId: messageId,
|
|
268
270
|
content: messageContent,
|
|
269
271
|
userId: data.selfId,
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
username: bot?.user?.name || `Bot-${data.selfId}`,
|
|
273
|
+
avatar: bot?.user?.avatar,
|
|
272
274
|
timestamp: Date.now(),
|
|
273
275
|
guildName: '', // 这个信息在前端会补充
|
|
274
276
|
channelType: 0, // 这个信息在前端会补充
|
|
@@ -504,7 +506,6 @@ export class ApiHandlers {
|
|
|
504
506
|
keepTempImages: this.config.keepTempImages,
|
|
505
507
|
loggerinfo: this.config.loggerinfo,
|
|
506
508
|
blockedPlatforms: this.config.blockedPlatforms || [],
|
|
507
|
-
chatContainerHeight: this.config.chatContainerHeight,
|
|
508
509
|
clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
|
|
509
510
|
}
|
|
510
511
|
}
|
|
@@ -553,17 +554,23 @@ export class ApiHandlers {
|
|
|
553
554
|
// 处理本地文件请求
|
|
554
555
|
private async handleLocalFileRequest(fileUrl: string) {
|
|
555
556
|
try {
|
|
556
|
-
|
|
557
|
-
const
|
|
558
|
-
let contentType = fileresponse.type
|
|
557
|
+
// 使用 fileURLToPath 转换 file:// URL 为系统路径
|
|
558
|
+
const filePath = fileURLToPath(fileUrl)
|
|
559
559
|
|
|
560
|
-
|
|
560
|
+
// 改用 node:fs 直接读取文件,避免 ctx.http.file 可能存在的编码或协议处理问题
|
|
561
|
+
const buffer = readFileSync(filePath)
|
|
562
|
+
const base64 = buffer.toString('base64')
|
|
563
|
+
|
|
564
|
+
// 使用 mime-types 库精确推断 MIME 类型
|
|
565
|
+
const contentType = mime.lookup(filePath) || 'application/octet-stream'
|
|
566
|
+
|
|
567
|
+
this.logInfo('成功读取本地文件:', { fileUrl, filePath, contentType })
|
|
561
568
|
|
|
562
569
|
return {
|
|
563
570
|
success: true,
|
|
564
|
-
base64:
|
|
571
|
+
base64: base64,
|
|
565
572
|
contentType: contentType,
|
|
566
|
-
dataUrl: `data:${contentType};base64,${
|
|
573
|
+
dataUrl: `data:${contentType};base64,${base64}`
|
|
567
574
|
}
|
|
568
575
|
} catch (error: any) {
|
|
569
576
|
this.logger.error('读取本地文件失败:', { fileUrl, error: error.message })
|
package/src/config.ts
CHANGED
|
@@ -6,11 +6,11 @@ export interface Config {
|
|
|
6
6
|
maxMessagesPerChannel: number
|
|
7
7
|
keepMessagesOnClear: number
|
|
8
8
|
keepTempImages: number
|
|
9
|
+
maxPersistImages: number
|
|
9
10
|
blockedPlatforms: Array<{
|
|
10
11
|
platformName: string
|
|
11
12
|
exactMatch: boolean
|
|
12
13
|
}>
|
|
13
|
-
chatContainerHeight: number
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export const Config: Schema<Config> = Schema.intersect([
|
|
@@ -18,6 +18,7 @@ export const Config: Schema<Config> = Schema.intersect([
|
|
|
18
18
|
maxMessagesPerChannel: Schema.number().default(500).description('每个群组最大保存消息数量').min(50).max(1500),
|
|
19
19
|
keepMessagesOnClear: Schema.number().default(50).description('手动清理历史记录时保留的消息数量').min(0).max(1000),
|
|
20
20
|
keepTempImages: Schema.number().default(50).description('发送消息保留的临时图片数量(最新的N张)').min(10).max(200),
|
|
21
|
+
maxPersistImages: Schema.number().default(100).description('持久化存储的机器人发送图片数量').min(10).max(500),
|
|
21
22
|
blockedPlatforms: Schema.array(Schema.object({
|
|
22
23
|
platformName: Schema.string().description('平台名称或关键词'),
|
|
23
24
|
exactMatch: Schema.boolean().default(false).description('完全匹配?如果关闭,包含关键词即屏蔽').default(true)
|
|
@@ -39,9 +40,6 @@ export const Config: Schema<Config> = Schema.intersect([
|
|
|
39
40
|
),
|
|
40
41
|
}).description('基础设置'),
|
|
41
42
|
|
|
42
|
-
Schema.object({
|
|
43
|
-
chatContainerHeight: Schema.number().default(80).description('手机端使用的视口高度(防止文本输入框被挡住)').min(50).max(100),
|
|
44
|
-
}).description('进阶设置'),
|
|
45
43
|
|
|
46
44
|
Schema.object({
|
|
47
45
|
clearIndexedDBOnStart: Schema.boolean().default(true).description('启动时强制清空IndexedDB缓存(适用于紧急情况,防止浏览器卡死)'),
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export async function apply(ctx: Context, config: Config) {
|
|
|
42
42
|
const fileManager = new FileManager(ctx, config)
|
|
43
43
|
const messageHandler = new MessageHandler(ctx, config, fileManager)
|
|
44
44
|
const apiHandlers = new ApiHandlers(ctx, config, fileManager, messageHandler)
|
|
45
|
-
const utils = new Utils(config)
|
|
45
|
+
const utils = new Utils(config, ctx)
|
|
46
46
|
|
|
47
47
|
// 初始化数据
|
|
48
48
|
const initialData = fileManager.readChatDataFromFile()
|
package/src/message-handler.ts
CHANGED
|
@@ -63,7 +63,7 @@ export class MessageHandler {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// 获取私聊用户昵称
|
|
66
|
-
if (session.isDirect && session.bot.getUser && typeof session.bot.getUser === 'function') {
|
|
66
|
+
if (session.userId && session.isDirect && session.bot.getUser && typeof session.bot.getUser === 'function') {
|
|
67
67
|
try {
|
|
68
68
|
const user = await session.bot.getUser(session.userId)
|
|
69
69
|
directUserName = user?.name || session.username || session.userId || '未知用户'
|
|
@@ -93,7 +93,7 @@ export class MessageHandler {
|
|
|
93
93
|
const channelInfo: ChannelInfo = {
|
|
94
94
|
id: session.channelId,
|
|
95
95
|
name: session.isDirect
|
|
96
|
-
? `私聊(${directUserName})`
|
|
96
|
+
? `私聊(${session.username || session.userId || directUserName})`
|
|
97
97
|
: `${guildName} (${session.channelId})`,
|
|
98
98
|
type: session.type || 0,
|
|
99
99
|
channelId: session.channelId,
|
|
@@ -226,9 +226,10 @@ export class MessageHandler {
|
|
|
226
226
|
|
|
227
227
|
const timestamp = Date.now()
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
// 优先使用 session.content,它包含了完整的消息内容(含标签)
|
|
230
|
+
let content = session.content || ''
|
|
230
231
|
|
|
231
|
-
if (session.event?.message?.elements) {
|
|
232
|
+
if (!content && session.event?.message?.elements) {
|
|
232
233
|
content = this.utils.extractTextContent(session.event.message.elements).trim()
|
|
233
234
|
}
|
|
234
235
|
|
package/src/utils.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
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'
|
|
2
7
|
|
|
3
8
|
export class Utils {
|
|
4
|
-
constructor(private config: Config) { }
|
|
9
|
+
constructor(private config: Config, private ctx?: Context) { }
|
|
5
10
|
|
|
6
11
|
// 检查平台是否被屏蔽
|
|
7
12
|
isPlatformBlocked(platform: string): boolean {
|
|
@@ -59,7 +64,46 @@ export class Utils {
|
|
|
59
64
|
return false
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
//
|
|
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内容,改为持久化存储
|
|
63
107
|
cleanBase64Content(obj: any): any {
|
|
64
108
|
if (obj === null || obj === undefined) {
|
|
65
109
|
return obj
|
|
@@ -67,7 +111,7 @@ export class Utils {
|
|
|
67
111
|
|
|
68
112
|
if (typeof obj === 'string') {
|
|
69
113
|
if (this.isBase64(obj)) {
|
|
70
|
-
return
|
|
114
|
+
return this.persistBase64Image(obj)
|
|
71
115
|
}
|
|
72
116
|
return obj
|
|
73
117
|
}
|
|
@@ -87,7 +131,7 @@ export class Utils {
|
|
|
87
131
|
key === 'data' ||
|
|
88
132
|
key === 'content'
|
|
89
133
|
) && this.isBase64(value)) {
|
|
90
|
-
cleaned[key] =
|
|
134
|
+
cleaned[key] = this.persistBase64Image(value)
|
|
91
135
|
} else {
|
|
92
136
|
cleaned[key] = this.cleanBase64Content(value)
|
|
93
137
|
}
|