koishi-plugin-chat-patch 3.0.2 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/vue/index.vue +60 -634
- package/client/web/dist/assets/AppIcon-DIku6-h0.png +0 -0
- package/client/web/dist/assets/AppIconDark-BYymN71t.png +0 -0
- package/client/web/dist/assets/CapacitorAppIcon-CSZwNhvi.png +0 -0
- package/client/web/dist/assets/CapacitorAppIconDark-BMvMOzsg.png +0 -0
- package/client/web/dist/assets/Chat-CTdyeaX2.css +1 -0
- package/client/web/dist/assets/Chat-DkZvD7d8.js +28 -0
- package/client/web/dist/assets/MealHungryPan-Beos3lRI.js +1 -0
- package/client/web/dist/assets/MsgBody-aZ4_aija.js +1 -0
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CPZjRLp6.js +67 -0
- package/client/web/dist/assets/NapcatAppIcon-C7VOAkW9.png +0 -0
- package/client/web/dist/assets/Vue-lEhHouxB.png +0 -0
- package/client/web/dist/assets/append_darwin-CqBNU62J.css +1 -0
- package/client/web/dist/assets/append_full_vibrancy-j0D2LM9m.css +1 -0
- package/client/web/dist/assets/append_linux-uhxeBqeC.css +1 -0
- package/client/web/dist/assets/append_linux_vibrancy-COiZq8MS.css +1 -0
- package/client/web/dist/assets/append_napcat-DV8CAyAd.css +1 -0
- package/client/web/dist/assets/append_new-407Lo5_V.css +1 -0
- package/client/web/dist/assets/append_vibrancy-6FHctkOY.css +1 -0
- package/client/web/dist/assets/append_win32-srmD-78y.css +1 -0
- package/client/web/dist/assets/cakes-CxTVKdnK.svg +189 -0
- package/client/web/dist/assets/core-DhEqZVGG.js +1 -0
- package/client/web/dist/assets/event-Cadp6m6J.js +1 -0
- package/client/web/dist/assets/index-CPTXiemn.js +4045 -0
- package/client/web/dist/assets/index-Cg_esKB0.js +1 -0
- package/client/web/dist/assets/index-Cln012sP.css +1 -0
- package/client/web/dist/assets//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223-CJKIKBn3.ttf +0 -0
- package/client/web/dist/assets//346/234/237/345/276/205-C90_OY60.png +0 -0
- package/client/web/dist/bcui/css/color-dark.css +35 -0
- package/client/web/dist/bcui/css/color-light.css +35 -0
- package/client/web/dist/bcui/css/style.css +468 -0
- package/client/web/dist/bcui/js/main.js +193 -0
- package/client/web/dist/css/append-dark.css +13 -0
- package/client/web/dist/css/append-light.css +13 -0
- package/client/web/dist/css/append-napcat-dark.css +14 -0
- package/client/web/dist/css/append-napcat-light.css +14 -0
- package/client/web/dist/favicon.ico +0 -0
- package/client/web/dist/img/icons/icon-for-fuck-apple.png +0 -0
- package/client/web/dist/img/icons/icon-maskable.png +0 -0
- package/client/web/dist/img/icons/icon.png +0 -0
- package/client/web/dist/img/icons/icon.svg +10 -0
- package/client/web/dist/index.html +22 -0
- package/client/web/dist/robots.txt +2 -0
- package/client/web/dist/sw.js +58 -0
- package/client/web/env.d.ts +56 -0
- package/client/web/index.html +21 -0
- package/client/web/package-lock.json +3419 -0
- package/client/web/package.json +62 -0
- package/client/web/public/bcui/css/color-dark.css +35 -0
- package/client/web/public/bcui/css/color-light.css +35 -0
- package/client/web/public/bcui/css/style.css +468 -0
- package/client/web/public/bcui/js/main.js +193 -0
- package/client/web/public/css/append-dark.css +13 -0
- package/client/web/public/css/append-light.css +13 -0
- package/client/web/public/css/append-napcat-dark.css +14 -0
- package/client/web/public/css/append-napcat-light.css +14 -0
- package/client/web/public/favicon.ico +0 -0
- package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
- package/client/web/public/img/icons/icon-maskable.png +0 -0
- package/client/web/public/img/icons/icon.png +0 -0
- package/client/web/public/img/icons/icon.svg +10 -0
- package/client/web/public/robots.txt +2 -0
- package/client/web/public/sw.js +58 -0
- package/client/web/scripts/copy-bcui.mjs +17 -0
- package/client/web/src/App.vue +1127 -0
- package/client/web/src/assets/css/append/append_darwin.css +68 -0
- package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
- package/client/web/src/assets/css/append/append_linux.css +125 -0
- package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
- package/client/web/src/assets/css/append/append_napcat.css +35 -0
- package/client/web/src/assets/css/append/append_new.css +843 -0
- package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
- package/client/web/src/assets/css/append/append_win32.css +109 -0
- package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
- package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
- package/client/web/src/assets/css/chat.css +2104 -0
- package/client/web/src/assets/css/msg.css +941 -0
- package/client/web/src/assets/css/options.css +811 -0
- package/client/web/src/assets/css/sys_notice.css +62 -0
- package/client/web/src/assets/css/view.css +2105 -0
- package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
- package/client/web/src/assets/img/Vue.png +0 -0
- package/client/web/src/assets/img/cakes.svg +189 -0
- package/client/web/src/assets/img/icons/AppIcon.png +0 -0
- package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
- package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
- package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/exit.png +0 -0
- package/client/web/src/assets/img/icons/pen.png +0 -0
- package/client/web/src/assets/img/stars.svg +89 -0
- package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
- package/client/web/src/assets/l10n/_About I10n.md +35 -0
- package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
- package/client/web/src/assets/l10n/zh-CN.po +1941 -0
- package/client/web/src/assets/logo.png +0 -0
- package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
- package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
- package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
- package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
- package/client/web/src/assets/qed.txt +1 -0
- package/client/web/src/components/AboutPan.vue +197 -0
- package/client/web/src/components/BulletinBody.vue +129 -0
- package/client/web/src/components/DepPan.vue +310 -0
- package/client/web/src/components/EmojiFace.vue +32 -0
- package/client/web/src/components/FacePan.vue +461 -0
- package/client/web/src/components/FileBody.vue +129 -0
- package/client/web/src/components/FileManager.vue +532 -0
- package/client/web/src/components/FriendBody.vue +54 -0
- package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
- package/client/web/src/components/History.vue +49 -0
- package/client/web/src/components/LazyLottie.vue +82 -0
- package/client/web/src/components/MergePan.vue +127 -0
- package/client/web/src/components/MsgBody.vue +1478 -0
- package/client/web/src/components/MusicPlayer.vue +549 -0
- package/client/web/src/components/NoticeBody.vue +168 -0
- package/client/web/src/components/QzonePermissionPan.vue +150 -0
- package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
- package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
- package/client/web/src/components/TinySessionBody.vue +51 -0
- package/client/web/src/components/UmamiInfoPan.vue +1549 -0
- package/client/web/src/components/UpdatePan.vue +263 -0
- package/client/web/src/components/ViewerCom.vue +1360 -0
- package/client/web/src/components/VoiceMsg.vue +626 -0
- package/client/web/src/components/WelPan.vue +894 -0
- package/client/web/src/components/msg-body/.gitkeep +0 -0
- package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
- package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
- package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
- package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
- package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
- package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
- package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
- package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
- package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
- package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
- package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
- package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
- package/client/web/src/components/notice-component/.gitkeep +0 -0
- package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
- package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
- package/client/web/src/components/tooltip/Tooltip.vue +50 -0
- package/client/web/src/components/tooltip/Tooltips.vue +14 -0
- package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
- package/client/web/src/function/base.ts +238 -0
- package/client/web/src/function/connect.ts +2042 -0
- package/client/web/src/function/debug.ts +10 -0
- package/client/web/src/function/elements/information.ts +141 -0
- package/client/web/src/function/elements/system.ts +101 -0
- package/client/web/src/function/elements/vueComp.ts +62 -0
- package/client/web/src/function/favicon.ts +76 -0
- package/client/web/src/function/input.ts +69 -0
- package/client/web/src/function/model/emoji.ts +127 -0
- package/client/web/src/function/model/img.ts +150 -0
- package/client/web/src/function/model/msg-body.ts +51 -0
- package/client/web/src/function/msg.ts +2674 -0
- package/client/web/src/function/notify.ts +225 -0
- package/client/web/src/function/option.ts +749 -0
- package/client/web/src/function/satori-model.ts +818 -0
- package/client/web/src/function/satori.ts +325 -0
- package/client/web/src/function/sender.ts +178 -0
- package/client/web/src/function/tooltip.ts +49 -0
- package/client/web/src/function/utils/appUtil.ts +2288 -0
- package/client/web/src/function/utils/avatarUtil.ts +31 -0
- package/client/web/src/function/utils/backgroundUtil.ts +110 -0
- package/client/web/src/function/utils/linkViewUtil.ts +101 -0
- package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
- package/client/web/src/function/utils/msgUtil.ts +1242 -0
- package/client/web/src/function/utils/pinyin.ts +141 -0
- package/client/web/src/function/utils/recordUtil.ts +187 -0
- package/client/web/src/function/utils/sessionUtil.ts +114 -0
- package/client/web/src/function/utils/systemUtil.ts +664 -0
- package/client/web/src/main.ts +91 -0
- package/client/web/src/pages/Chat.vue +3453 -0
- package/client/web/src/pages/Friends.vue +636 -0
- package/client/web/src/pages/Info.vue +717 -0
- package/client/web/src/pages/Messages.vue +875 -0
- package/client/web/src/pages/Options.vue +82 -0
- package/client/web/src/pages/Qzone.vue +1178 -0
- package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
- package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
- package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
- package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
- package/client/web/src/pages/options/OptAccount.vue +107 -0
- package/client/web/src/pages/options/OptAddon.vue +185 -0
- package/client/web/src/pages/options/OptDev.vue +704 -0
- package/client/web/src/pages/options/OptFunction.vue +403 -0
- package/client/web/src/pages/options/OptInfo.vue +165 -0
- package/client/web/src/pages/options/OptView.vue +846 -0
- package/client/web/src/runtime/backend.ts +310 -0
- package/client/web/src/state/auth.ts +14 -0
- package/client/web/src/state/chat.ts +36 -0
- package/client/web/src/state/connection.ts +20 -0
- package/client/web/src/state/contact.ts +37 -0
- package/client/web/src/state/qzone.ts +18 -0
- package/client/web/src/state/sessionHistory.ts +25 -0
- package/client/web/src/state/settings.ts +18 -0
- package/client/web/src/state/sticker.ts +10 -0
- package/client/web/src/state/ui.ts +56 -0
- package/client/web/tsconfig.json +27 -0
- package/client/web/tsconfig.tsbuildinfo +1 -0
- package/client/web/vite.config.ts +33 -0
- package/dist/index.js +1 -12
- package/dist/style.css +1 -1
- package/lib/index.js +2070 -2077
- package/package.json +15 -2
- package/readme.md +0 -22
- package/src/bootstrap.ts +71 -0
- package/src/cache.ts +319 -0
- package/src/config.ts +23 -35
- package/src/database.ts +650 -0
- package/src/index.ts +27 -59
- package/src/media.ts +302 -0
- package/src/recorder.ts +175 -0
- package/src/self-message.ts +456 -0
- package/src/server.d.ts +26 -0
- package/src/types.ts +108 -48
- package/src/web.ts +712 -0
- package/client/vue/chat-logic.ts +0 -683
- package/client/vue/composables/useChatActions.ts +0 -61
- package/client/vue/composables/useChatData.ts +0 -185
- package/client/vue/composables/useImageCache.ts +0 -281
- package/client/vue/composables/useVideoCache.ts +0 -125
- package/client/vue/types.ts +0 -72
- package/lib/api-handlers.d.ts +0 -25
- package/lib/config.d.ts +0 -14
- package/lib/file-manager.d.ts +0 -124
- package/lib/index.d.ts +0 -17
- package/lib/logger.d.ts +0 -9
- package/lib/message-handler.d.ts +0 -35
- package/lib/types.d.ts +0 -57
- package/lib/utils.d.ts +0 -18
- package/src/api-handlers.ts +0 -646
- package/src/file-manager.ts +0 -1436
- package/src/message-handler.ts +0 -507
- package/src/utils.ts +0 -189
package/src/index.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
import { Context } from 'koishi'
|
|
2
2
|
import { Console } from '@koishijs/console'
|
|
3
|
-
import { Context, h } from 'koishi'
|
|
4
3
|
import path from 'node:path'
|
|
5
4
|
|
|
6
|
-
import { MessageHandler } from './message-handler'
|
|
7
|
-
import { FileManager } from './file-manager'
|
|
8
|
-
import { ApiHandlers } from './api-handlers'
|
|
9
5
|
import { Config } from './config'
|
|
10
6
|
import { createPluginLogger } from './logger'
|
|
11
|
-
import {
|
|
7
|
+
import { ContactCacheService } from './cache'
|
|
8
|
+
import { ChatDatabase } from './database'
|
|
9
|
+
import { Recorder } from './recorder'
|
|
10
|
+
import { MediaManager } from './media'
|
|
11
|
+
import { SelfMessageRecorder } from './self-message'
|
|
12
|
+
import { registerBootstrap } from './bootstrap'
|
|
13
|
+
import { registerWeb } from './web'
|
|
12
14
|
|
|
13
15
|
export const name = 'chat-patch'
|
|
14
16
|
export const reusable = false
|
|
15
|
-
export const filter =
|
|
17
|
+
export const filter = false
|
|
16
18
|
export const inject = {
|
|
17
|
-
required: ['console']
|
|
19
|
+
required: ['console', 'server', 'satori.server'],
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
declare module 'koishi' {
|
|
@@ -24,12 +26,10 @@ declare module 'koishi' {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export const usage = `
|
|
27
|
-
|
|
28
29
|
---
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
暂时只支持接受图文消息 / 发送文字消息
|
|
31
|
+
基于 Satori 协议的 Koishi 后台聊天室。
|
|
32
|
+
需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
`
|
|
@@ -39,52 +39,21 @@ export { Config } from './config'
|
|
|
39
39
|
export async function apply(ctx: Context, config: Config) {
|
|
40
40
|
const pluginLogger = createPluginLogger(ctx.logger('chat-patch'), config)
|
|
41
41
|
|
|
42
|
-
const
|
|
43
|
-
await
|
|
44
|
-
|
|
45
|
-
const messageHandler = new MessageHandler(ctx, config, fileManager, pluginLogger)
|
|
46
|
-
const apiHandlers = new ApiHandlers(ctx, config, fileManager, messageHandler, pluginLogger)
|
|
47
|
-
const utils = new Utils(config, ctx)
|
|
42
|
+
const database = new ChatDatabase(ctx, config, pluginLogger)
|
|
43
|
+
await database.initialize()
|
|
44
|
+
const contactCache = new ContactCacheService(ctx, database, pluginLogger)
|
|
48
45
|
|
|
49
|
-
const
|
|
46
|
+
const media = new MediaManager(ctx, config, database, pluginLogger)
|
|
47
|
+
media.start()
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
频道数量: Object.keys(metadata.channels).reduce((total, botId) =>
|
|
54
|
-
total + Object.keys(metadata.channels[botId] || {}).length, 0),
|
|
55
|
-
置顶机器人数量: metadata.pinnedBots.length,
|
|
56
|
-
置顶频道数量: metadata.pinnedChannels.length
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
ctx.on('message', (session) => {
|
|
60
|
-
if (utils.isPlatformBlocked(session.platform || 'unknown')) return
|
|
61
|
-
|
|
62
|
-
messageHandler.recordUserMessage(session, Date.now())
|
|
63
|
-
})
|
|
49
|
+
const recorder = new Recorder(ctx, config, database, media, contactCache, pluginLogger)
|
|
50
|
+
recorder.start()
|
|
64
51
|
|
|
65
|
-
ctx
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
messageHandler.recordBotMessage(session, Date.now())
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
let cleanupDelayTimer: (() => void) | undefined
|
|
72
|
-
let cleanupInterval: (() => void) | undefined
|
|
73
|
-
|
|
74
|
-
ctx.on('ready', async () => {
|
|
75
|
-
pluginLogger.logInfo('插件启动完成,开始监听消息')
|
|
76
|
-
|
|
77
|
-
// 延后清理,避免和 Koishi 刚 ready 时的资源竞争。
|
|
78
|
-
cleanupDelayTimer = ctx.setTimeout(() => {
|
|
79
|
-
void fileManager.cleanupExcessMessagesInStorage()
|
|
80
|
-
}, 15000)
|
|
81
|
-
|
|
82
|
-
cleanupInterval = ctx.setInterval(() => {
|
|
83
|
-
void fileManager.cleanupExcessMessagesInStorage()
|
|
84
|
-
}, 300000)
|
|
85
|
-
})
|
|
52
|
+
const selfMessages = new SelfMessageRecorder(ctx, config, database, pluginLogger)
|
|
53
|
+
selfMessages.start()
|
|
86
54
|
|
|
87
|
-
|
|
55
|
+
registerBootstrap(ctx, config, database, pluginLogger)
|
|
56
|
+
registerWeb(ctx, config, database, contactCache, pluginLogger)
|
|
88
57
|
|
|
89
58
|
ctx.console.addEntry({
|
|
90
59
|
dev: path.resolve(__dirname, '../client/index.ts'),
|
|
@@ -92,10 +61,9 @@ export async function apply(ctx: Context, config: Config) {
|
|
|
92
61
|
})
|
|
93
62
|
|
|
94
63
|
ctx.on('dispose', () => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
pluginLogger.logInfo('插件已卸载,所有待处理的消息已写入')
|
|
64
|
+
selfMessages.dispose()
|
|
65
|
+
media.dispose()
|
|
66
|
+
void database.dispose()
|
|
67
|
+
pluginLogger.logInfo('chat-patch 已卸载')
|
|
100
68
|
})
|
|
101
69
|
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Context } from 'koishi'
|
|
2
|
+
import {} from '@koishijs/plugin-server'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { createReadStream, promises as fs } from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import FileType from 'file-type'
|
|
8
|
+
|
|
9
|
+
import { Config } from './config'
|
|
10
|
+
import { ChatDatabase } from './database'
|
|
11
|
+
import { PluginLogger } from './logger'
|
|
12
|
+
|
|
13
|
+
function toExtension(value: string): string {
|
|
14
|
+
const ext = value.startsWith('.') ? value : `.${value}`
|
|
15
|
+
return ext.toLowerCase()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toLocalFilePath(value: string): string | null {
|
|
19
|
+
if (value.startsWith('file://')) {
|
|
20
|
+
try {
|
|
21
|
+
return fileURLToPath(value)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\')) return value
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class MediaManager {
|
|
31
|
+
private mediaDir: string
|
|
32
|
+
private uploadDir: string
|
|
33
|
+
private cleanupTimer?: () => void
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private ctx: Context,
|
|
37
|
+
private config: Config,
|
|
38
|
+
private database: ChatDatabase,
|
|
39
|
+
private logger: PluginLogger,
|
|
40
|
+
) {
|
|
41
|
+
this.mediaDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'media')
|
|
42
|
+
this.uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start() {
|
|
46
|
+
const route = `${this.config.basePath}/web/media`
|
|
47
|
+
this.ctx.server.get(route, async (koa) => {
|
|
48
|
+
const url = String(koa.query.url ?? '')
|
|
49
|
+
if (!url) {
|
|
50
|
+
koa.status = 400
|
|
51
|
+
koa.body = 'missing url'
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const filePath = await this.cacheUrl(url)
|
|
55
|
+
if (!filePath) {
|
|
56
|
+
koa.status = 502
|
|
57
|
+
koa.body = 'download failed'
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const size = (await fs.stat(filePath)).size
|
|
61
|
+
koa.type = path.extname(filePath) || 'application/octet-stream'
|
|
62
|
+
koa.set('Accept-Ranges', 'bytes')
|
|
63
|
+
|
|
64
|
+
const range = koa.headers.range
|
|
65
|
+
if (range) {
|
|
66
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
|
|
67
|
+
if (!match) {
|
|
68
|
+
koa.status = 416
|
|
69
|
+
koa.set('Content-Range', `bytes */${size}`)
|
|
70
|
+
koa.body = ''
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
let start = 0
|
|
74
|
+
let end = size - 1
|
|
75
|
+
if (match[1] === '' && match[2]) {
|
|
76
|
+
start = Math.max(0, size - Number(match[2]))
|
|
77
|
+
} else if (match[1]) {
|
|
78
|
+
start = Number(match[1])
|
|
79
|
+
end = match[2] ? Number(match[2]) : end
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
|
|
82
|
+
koa.status = 416
|
|
83
|
+
koa.set('Content-Range', `bytes */${size}`)
|
|
84
|
+
koa.body = ''
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (end >= size) end = size - 1
|
|
88
|
+
if (end < start) end = start
|
|
89
|
+
koa.status = 206
|
|
90
|
+
koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
91
|
+
koa.set('Content-Length', String(end - start + 1))
|
|
92
|
+
koa.body = createReadStream(filePath, { start, end })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
koa.set('Content-Length', String(size))
|
|
97
|
+
koa.body = createReadStream(filePath)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
this.cleanupTimer = this.ctx.setInterval(() => {
|
|
101
|
+
void this.cleanup()
|
|
102
|
+
}, 10 * 60 * 1000)
|
|
103
|
+
|
|
104
|
+
// 每次插件启动时先按全局文件数清理一次
|
|
105
|
+
void this.cleanup()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
dispose() {
|
|
109
|
+
this.cleanupTimer?.()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async cacheUrl(url: string, channelId = ''): Promise<string | null> {
|
|
113
|
+
try {
|
|
114
|
+
const filePath = await this.resolveAndDownload(url, channelId)
|
|
115
|
+
if (filePath) {
|
|
116
|
+
await this.database.recordMedia(url, filePath, channelId)
|
|
117
|
+
}
|
|
118
|
+
return filePath
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.logger.warn('媒体缓存失败:', url, error)
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async resolveAndDownload(url: string, channelId: string): Promise<string | null> {
|
|
126
|
+
try {
|
|
127
|
+
await fs.mkdir(this.mediaDir, { recursive: true })
|
|
128
|
+
const localSource = toLocalFilePath(url)
|
|
129
|
+
if (localSource && await this.exists(localSource)) {
|
|
130
|
+
const hash = createHash('md5').update(url).digest('hex')
|
|
131
|
+
const ext = path.extname(localSource).toLowerCase() || '.bin'
|
|
132
|
+
const filePath = path.join(this.mediaDir, `${hash}${ext}`)
|
|
133
|
+
if (path.normalize(filePath) !== path.normalize(localSource)) {
|
|
134
|
+
if (!(await this.exists(filePath))) {
|
|
135
|
+
await fs.copyFile(localSource, filePath)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await this.database.recordMedia(url, filePath, channelId)
|
|
139
|
+
this.logger.logInfo('本地媒体已缓存:', filePath)
|
|
140
|
+
return filePath
|
|
141
|
+
}
|
|
142
|
+
const cached = await this.database.getMediaPath(url)
|
|
143
|
+
if (cached && await this.exists(cached)) {
|
|
144
|
+
return this.migrateCachedMedia(url, cached, channelId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const hash = createHash('md5').update(url).digest('hex')
|
|
148
|
+
const urlExt = path.extname(new URL(url).pathname)
|
|
149
|
+
const fallbackPath = path.join(this.mediaDir, `${hash}${urlExt || '.bin'}`)
|
|
150
|
+
if (await this.exists(fallbackPath)) {
|
|
151
|
+
return this.migrateCachedMedia(url, fallbackPath, channelId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const response = await fetch(url, {
|
|
155
|
+
headers: {
|
|
156
|
+
'User-Agent': 'Mozilla/5.0 KoishiChatPatch/1.0',
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
163
|
+
const ext = await this.detectExtension(url, buffer)
|
|
164
|
+
const filename = `${hash}${ext}`
|
|
165
|
+
const filePath = path.join(this.mediaDir, filename)
|
|
166
|
+
await fs.writeFile(filePath, buffer)
|
|
167
|
+
this.logger.logInfo('媒体已缓存:', filename, buffer.length)
|
|
168
|
+
return filePath
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async detectExtension(url: string, buffer: Buffer): Promise<string> {
|
|
175
|
+
try {
|
|
176
|
+
const detected = await FileType.fromBuffer(buffer)
|
|
177
|
+
if (detected?.ext) return toExtension(detected.ext)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
this.logger.logInfo('媒体类型识别失败,回退 URL 后缀:', url, error)
|
|
180
|
+
}
|
|
181
|
+
const ext = path.extname(new URL(url).pathname)
|
|
182
|
+
return ext && ext !== '.bin' ? ext.toLowerCase() : '.bin'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async migrateCachedMedia(url: string, filePath: string, channelId: string): Promise<string> {
|
|
186
|
+
try {
|
|
187
|
+
const buffer = await fs.readFile(filePath)
|
|
188
|
+
const ext = await this.detectExtension(url, buffer)
|
|
189
|
+
const currentExt = path.extname(filePath).toLowerCase()
|
|
190
|
+
if (ext === '.bin' || ext === currentExt) return filePath
|
|
191
|
+
|
|
192
|
+
const newPath = path.join(
|
|
193
|
+
path.dirname(filePath),
|
|
194
|
+
`${path.basename(filePath, path.extname(filePath))}${ext}`,
|
|
195
|
+
)
|
|
196
|
+
if (newPath === filePath) return filePath
|
|
197
|
+
if (await this.exists(newPath)) {
|
|
198
|
+
await fs.unlink(filePath)
|
|
199
|
+
} else {
|
|
200
|
+
await fs.rename(filePath, newPath)
|
|
201
|
+
}
|
|
202
|
+
await this.database.recordMedia(url, newPath, channelId)
|
|
203
|
+
return newPath
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.logger.warn('旧媒体缓存重命名失败:', url, error)
|
|
206
|
+
return filePath
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async cleanup() {
|
|
211
|
+
try {
|
|
212
|
+
const allFiles: Array<{ filePath: string; mtimeMs: number; channelId: string }> = []
|
|
213
|
+
for (const dir of [this.mediaDir, this.uploadDir]) {
|
|
214
|
+
try {
|
|
215
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (!entry.isFile()) continue
|
|
218
|
+
const filePath = path.join(dir, entry.name)
|
|
219
|
+
try {
|
|
220
|
+
const stat = await fs.stat(filePath)
|
|
221
|
+
allFiles.push({ filePath, mtimeMs: stat.mtimeMs, channelId: '' })
|
|
222
|
+
} catch {
|
|
223
|
+
// 单个文件读取失败不影响其他文件清理
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// 目录不存在时跳过
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!allFiles.length) return
|
|
231
|
+
|
|
232
|
+
const metadata = await this.database.getAllMedia()
|
|
233
|
+
const channelByPath = new Map(metadata.map((item) => [path.normalize(item.filePath), item.channelId]))
|
|
234
|
+
const uploadRoot = path.normalize(this.uploadDir)
|
|
235
|
+
for (const file of allFiles) {
|
|
236
|
+
const normalized = path.normalize(file.filePath)
|
|
237
|
+
file.channelId = channelByPath.get(normalized) ?? ''
|
|
238
|
+
if (!file.channelId && normalized.startsWith(uploadRoot)) {
|
|
239
|
+
file.channelId = 'upload'
|
|
240
|
+
}
|
|
241
|
+
if (!file.channelId) file.channelId = 'unknown'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (allFiles.length <= this.config.maxMediaFiles) return
|
|
245
|
+
|
|
246
|
+
const groups = new Map<string, typeof allFiles>()
|
|
247
|
+
for (const file of allFiles) {
|
|
248
|
+
const list = groups.get(file.channelId) ?? []
|
|
249
|
+
list.push(file)
|
|
250
|
+
groups.set(file.channelId, list)
|
|
251
|
+
}
|
|
252
|
+
for (const list of groups.values()) {
|
|
253
|
+
list.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 按频道/目录公平分配,避免一个群独占全部名额
|
|
257
|
+
const keep = new Set<string>()
|
|
258
|
+
const groupList = [...groups.values()]
|
|
259
|
+
let cursor = 0
|
|
260
|
+
for (let count = 0; count < this.config.maxMediaFiles && count < allFiles.length; count++) {
|
|
261
|
+
let picked = false
|
|
262
|
+
for (let offset = 0; offset < groupList.length; offset++) {
|
|
263
|
+
const group = groupList[(cursor + offset) % groupList.length]
|
|
264
|
+
const file = group.shift()
|
|
265
|
+
if (file) {
|
|
266
|
+
keep.add(file.filePath)
|
|
267
|
+
cursor = (cursor + offset + 1) % groupList.length
|
|
268
|
+
picked = true
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (!picked) break
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let removed = 0
|
|
276
|
+
for (const file of allFiles) {
|
|
277
|
+
if (keep.has(file.filePath)) continue
|
|
278
|
+
try {
|
|
279
|
+
await fs.unlink(file.filePath)
|
|
280
|
+
removed += 1
|
|
281
|
+
} catch {
|
|
282
|
+
// 文件可能已被外部删除
|
|
283
|
+
}
|
|
284
|
+
await this.database.removeMediaByPath(file.filePath).catch(() => undefined)
|
|
285
|
+
}
|
|
286
|
+
if (removed > 0) {
|
|
287
|
+
this.logger.logInfo('媒体缓存已清理', removed, '个文件')
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// 清理失败时不阻塞插件启动
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async exists(filePath: string): Promise<boolean> {
|
|
295
|
+
try {
|
|
296
|
+
await fs.access(filePath)
|
|
297
|
+
return true
|
|
298
|
+
} catch {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi'
|
|
2
|
+
|
|
3
|
+
import { Config } from './config'
|
|
4
|
+
import { ContactCacheService } from './cache'
|
|
5
|
+
import { ChatDatabase } from './database'
|
|
6
|
+
import { MediaManager } from './media'
|
|
7
|
+
import { MessageRecord } from './types'
|
|
8
|
+
import { PluginLogger } from './logger'
|
|
9
|
+
|
|
10
|
+
const MESSAGE_TYPES = new Set(['message', 'message-created'])
|
|
11
|
+
|
|
12
|
+
function normalizeGroupId(value: string): string {
|
|
13
|
+
const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
|
|
14
|
+
const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
|
|
15
|
+
return wrapped ? wrapped[1] : raw || value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Recorder {
|
|
19
|
+
constructor(
|
|
20
|
+
private ctx: Context,
|
|
21
|
+
private config: Config,
|
|
22
|
+
private database: ChatDatabase,
|
|
23
|
+
private media: MediaManager,
|
|
24
|
+
private contactCache: ContactCacheService,
|
|
25
|
+
private logger: PluginLogger,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
start() {
|
|
29
|
+
this.ctx.on('internal/session', (session: Session) => {
|
|
30
|
+
void this.handleSession(session)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private isBlocked(platform: string): boolean {
|
|
35
|
+
return (this.config.blockedPlatforms ?? []).some((item) => {
|
|
36
|
+
return item.exactMatch
|
|
37
|
+
? platform === item.platformName
|
|
38
|
+
: platform.includes(item.platformName)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async handleSession(session: Session) {
|
|
43
|
+
const platform = session.platform || 'unknown'
|
|
44
|
+
if (this.isBlocked(platform)) return
|
|
45
|
+
if (!MESSAGE_TYPES.has(session.type)) return
|
|
46
|
+
|
|
47
|
+
const event = session.toJSON()
|
|
48
|
+
const message = event.message
|
|
49
|
+
const record: MessageRecord = {
|
|
50
|
+
id: message?.id || `local-${event.sn}`,
|
|
51
|
+
sequence: event.sn,
|
|
52
|
+
type: session.type,
|
|
53
|
+
platform,
|
|
54
|
+
selfId: session.selfId,
|
|
55
|
+
channelId: session.channelId,
|
|
56
|
+
guildId: session.guildId,
|
|
57
|
+
userId: session.userId,
|
|
58
|
+
timestamp: session.timestamp,
|
|
59
|
+
timestampMs: session.timestamp,
|
|
60
|
+
receivedAt: Date.now(),
|
|
61
|
+
content: session.content || message?.content,
|
|
62
|
+
elements: message?.elements as unknown[] | undefined,
|
|
63
|
+
raw: event,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await this.database.appendMessage(record)
|
|
68
|
+
void this.cacheMessageContacts(event).catch((error) => {
|
|
69
|
+
this.logger.warn('缓存消息联系人失败:', error)
|
|
70
|
+
})
|
|
71
|
+
void this.cacheMessageMedia(event).catch((error) => {
|
|
72
|
+
this.logger.warn('异步缓存消息媒体失败:', error)
|
|
73
|
+
})
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.logger.warn('写入历史消息失败:', error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async cacheMessageContacts(event: ReturnType<Session['toJSON']>) {
|
|
80
|
+
const platform = event.platform || ''
|
|
81
|
+
const selfId = event.selfId || ''
|
|
82
|
+
const user = event.user
|
|
83
|
+
const guild = event.guild
|
|
84
|
+
const channel = event.channel
|
|
85
|
+
const member = event.member
|
|
86
|
+
const userId = user?.id || ''
|
|
87
|
+
const guildId = guild?.id || ''
|
|
88
|
+
const channelId = channel?.id || ''
|
|
89
|
+
const channelType = String(channel?.type ?? '')
|
|
90
|
+
const isPrivateChannel = channelType === '1'
|
|
91
|
+
|| ['direct', 'private'].includes(channelType.toLowerCase())
|
|
92
|
+
|| (!channelType && /^private:/i.test(channelId))
|
|
93
|
+
const groupId = guildId || (isPrivateChannel ? '' : normalizeGroupId(channelId)) || ''
|
|
94
|
+
const userName = user?.name || user?.nick || member?.nick || member?.name || ''
|
|
95
|
+
const userAvatar = user?.avatar || member?.avatar || ''
|
|
96
|
+
const groupName = guild?.name || channel?.name || ''
|
|
97
|
+
const groupAvatar = guild?.avatar || ''
|
|
98
|
+
|
|
99
|
+
if (groupId) {
|
|
100
|
+
const groupItem = await this.contactCache.getGroup(
|
|
101
|
+
platform,
|
|
102
|
+
selfId,
|
|
103
|
+
groupId,
|
|
104
|
+
guildId || groupId,
|
|
105
|
+
channelId || groupId,
|
|
106
|
+
groupName,
|
|
107
|
+
groupAvatar,
|
|
108
|
+
channel?.type,
|
|
109
|
+
)
|
|
110
|
+
if (userId) {
|
|
111
|
+
const userItem = await this.contactCache.getUser(
|
|
112
|
+
platform,
|
|
113
|
+
selfId,
|
|
114
|
+
userId,
|
|
115
|
+
guildId || groupId,
|
|
116
|
+
undefined,
|
|
117
|
+
userName,
|
|
118
|
+
userAvatar,
|
|
119
|
+
)
|
|
120
|
+
await this.contactCache.recordGroupMember(
|
|
121
|
+
platform,
|
|
122
|
+
selfId,
|
|
123
|
+
groupId,
|
|
124
|
+
{
|
|
125
|
+
id: userId,
|
|
126
|
+
name: userItem?.name || userName || userId,
|
|
127
|
+
avatar: userItem?.avatar || userAvatar || undefined,
|
|
128
|
+
raw: {
|
|
129
|
+
id: userId,
|
|
130
|
+
user_id: userId,
|
|
131
|
+
nickname: userName || userItem?.name,
|
|
132
|
+
card: member?.nick || member?.name || '',
|
|
133
|
+
avatar: userItem?.avatar || userAvatar || undefined,
|
|
134
|
+
role: member?.title || '',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if (!groupItem) this.logger.logInfo('群组缓存未回源到可用身份:', groupId)
|
|
140
|
+
} else if (userId) {
|
|
141
|
+
await this.contactCache.getUser(
|
|
142
|
+
platform,
|
|
143
|
+
selfId,
|
|
144
|
+
userId,
|
|
145
|
+
undefined,
|
|
146
|
+
channelId,
|
|
147
|
+
userName,
|
|
148
|
+
userAvatar,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async cacheMessageMedia(event: ReturnType<Session['toJSON']>) {
|
|
154
|
+
const elements = Array.isArray(event.message?.elements) ? event.message.elements : []
|
|
155
|
+
const channelId = String(
|
|
156
|
+
event.channel?.id
|
|
157
|
+
|| event.guild?.id
|
|
158
|
+
|| '',
|
|
159
|
+
)
|
|
160
|
+
const urls: string[] = []
|
|
161
|
+
const collect = (list: unknown[]) => {
|
|
162
|
+
for (const raw of list) {
|
|
163
|
+
const element = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
|
|
164
|
+
const attrs = typeof element.attrs === 'object' && element.attrs !== null ? element.attrs as Record<string, unknown> : {}
|
|
165
|
+
if (['img', 'image', 'mface', 'audio', 'video', 'file'].includes(String(element.type))) {
|
|
166
|
+
const url = String(attrs.src ?? attrs.url ?? attrs.file ?? '')
|
|
167
|
+
if (url) urls.push(url)
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(element.children)) collect(element.children)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
collect(elements)
|
|
173
|
+
await Promise.allSettled(urls.map((url) => this.media.cacheUrl(url, channelId)))
|
|
174
|
+
}
|
|
175
|
+
}
|