koishi-plugin-chat-patch 3.0.2 → 5.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.
Files changed (194) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/env.d.ts +56 -0
  3. package/client/web/index.html +21 -0
  4. package/client/web/package-lock.json +3419 -0
  5. package/client/web/package.json +62 -0
  6. package/client/web/public/bcui/css/color-dark.css +35 -0
  7. package/client/web/public/bcui/css/color-light.css +35 -0
  8. package/client/web/public/bcui/css/style.css +468 -0
  9. package/client/web/public/bcui/js/main.js +193 -0
  10. package/client/web/public/css/append-dark.css +13 -0
  11. package/client/web/public/css/append-light.css +13 -0
  12. package/client/web/public/css/append-napcat-dark.css +14 -0
  13. package/client/web/public/css/append-napcat-light.css +14 -0
  14. package/client/web/public/favicon.ico +0 -0
  15. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  16. package/client/web/public/img/icons/icon-maskable.png +0 -0
  17. package/client/web/public/img/icons/icon.png +0 -0
  18. package/client/web/public/img/icons/icon.svg +10 -0
  19. package/client/web/public/robots.txt +2 -0
  20. package/client/web/public/sw.js +58 -0
  21. package/client/web/scripts/copy-bcui.mjs +17 -0
  22. package/client/web/src/App.vue +1127 -0
  23. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  24. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  25. package/client/web/src/assets/css/append/append_linux.css +125 -0
  26. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  27. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  28. package/client/web/src/assets/css/append/append_new.css +843 -0
  29. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  30. package/client/web/src/assets/css/append/append_win32.css +109 -0
  31. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  32. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  33. package/client/web/src/assets/css/chat.css +2104 -0
  34. package/client/web/src/assets/css/msg.css +941 -0
  35. package/client/web/src/assets/css/options.css +811 -0
  36. package/client/web/src/assets/css/sys_notice.css +62 -0
  37. package/client/web/src/assets/css/view.css +2105 -0
  38. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  39. package/client/web/src/assets/img/Vue.png +0 -0
  40. package/client/web/src/assets/img/cakes.svg +189 -0
  41. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  42. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  43. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  44. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  45. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  46. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  47. package/client/web/src/assets/img/icons/exit.png +0 -0
  48. package/client/web/src/assets/img/icons/pen.png +0 -0
  49. package/client/web/src/assets/img/stars.svg +89 -0
  50. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  51. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  52. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  53. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  54. package/client/web/src/assets/logo.png +0 -0
  55. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  56. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  57. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  58. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  59. package/client/web/src/assets/qed.txt +1 -0
  60. package/client/web/src/components/AboutPan.vue +197 -0
  61. package/client/web/src/components/BulletinBody.vue +129 -0
  62. package/client/web/src/components/DepPan.vue +310 -0
  63. package/client/web/src/components/EmojiFace.vue +32 -0
  64. package/client/web/src/components/FacePan.vue +461 -0
  65. package/client/web/src/components/FileBody.vue +129 -0
  66. package/client/web/src/components/FileManager.vue +532 -0
  67. package/client/web/src/components/FriendBody.vue +54 -0
  68. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  69. package/client/web/src/components/History.vue +49 -0
  70. package/client/web/src/components/LazyLottie.vue +82 -0
  71. package/client/web/src/components/MergePan.vue +127 -0
  72. package/client/web/src/components/MsgBody.vue +1478 -0
  73. package/client/web/src/components/MusicPlayer.vue +549 -0
  74. package/client/web/src/components/NoticeBody.vue +168 -0
  75. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  76. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  77. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  78. package/client/web/src/components/TinySessionBody.vue +51 -0
  79. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  80. package/client/web/src/components/UpdatePan.vue +263 -0
  81. package/client/web/src/components/ViewerCom.vue +1360 -0
  82. package/client/web/src/components/VoiceMsg.vue +626 -0
  83. package/client/web/src/components/WelPan.vue +894 -0
  84. package/client/web/src/components/msg-body/.gitkeep +0 -0
  85. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  86. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  87. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  88. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  89. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  90. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  91. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  92. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  93. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  94. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  95. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  96. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  97. package/client/web/src/components/notice-component/.gitkeep +0 -0
  98. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  99. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  100. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  101. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  102. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  103. package/client/web/src/function/base.ts +238 -0
  104. package/client/web/src/function/connect.ts +2042 -0
  105. package/client/web/src/function/debug.ts +10 -0
  106. package/client/web/src/function/elements/information.ts +141 -0
  107. package/client/web/src/function/elements/system.ts +101 -0
  108. package/client/web/src/function/elements/vueComp.ts +62 -0
  109. package/client/web/src/function/favicon.ts +76 -0
  110. package/client/web/src/function/input.ts +69 -0
  111. package/client/web/src/function/model/emoji.ts +127 -0
  112. package/client/web/src/function/model/img.ts +150 -0
  113. package/client/web/src/function/model/msg-body.ts +51 -0
  114. package/client/web/src/function/msg.ts +2674 -0
  115. package/client/web/src/function/notify.ts +225 -0
  116. package/client/web/src/function/option.ts +749 -0
  117. package/client/web/src/function/satori-model.ts +818 -0
  118. package/client/web/src/function/satori.ts +325 -0
  119. package/client/web/src/function/sender.ts +178 -0
  120. package/client/web/src/function/tooltip.ts +49 -0
  121. package/client/web/src/function/utils/appUtil.ts +2288 -0
  122. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  123. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  124. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  125. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  126. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  127. package/client/web/src/function/utils/pinyin.ts +141 -0
  128. package/client/web/src/function/utils/recordUtil.ts +187 -0
  129. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  130. package/client/web/src/function/utils/systemUtil.ts +664 -0
  131. package/client/web/src/main.ts +91 -0
  132. package/client/web/src/pages/Chat.vue +3453 -0
  133. package/client/web/src/pages/Friends.vue +636 -0
  134. package/client/web/src/pages/Info.vue +717 -0
  135. package/client/web/src/pages/Messages.vue +875 -0
  136. package/client/web/src/pages/Options.vue +82 -0
  137. package/client/web/src/pages/Qzone.vue +1178 -0
  138. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  139. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  140. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  141. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  142. package/client/web/src/pages/options/OptAccount.vue +107 -0
  143. package/client/web/src/pages/options/OptAddon.vue +185 -0
  144. package/client/web/src/pages/options/OptDev.vue +704 -0
  145. package/client/web/src/pages/options/OptFunction.vue +403 -0
  146. package/client/web/src/pages/options/OptInfo.vue +165 -0
  147. package/client/web/src/pages/options/OptView.vue +846 -0
  148. package/client/web/src/runtime/backend.ts +310 -0
  149. package/client/web/src/state/auth.ts +14 -0
  150. package/client/web/src/state/chat.ts +36 -0
  151. package/client/web/src/state/connection.ts +20 -0
  152. package/client/web/src/state/contact.ts +37 -0
  153. package/client/web/src/state/qzone.ts +18 -0
  154. package/client/web/src/state/sessionHistory.ts +25 -0
  155. package/client/web/src/state/settings.ts +18 -0
  156. package/client/web/src/state/sticker.ts +10 -0
  157. package/client/web/src/state/ui.ts +56 -0
  158. package/client/web/tsconfig.json +27 -0
  159. package/client/web/tsconfig.tsbuildinfo +1 -0
  160. package/client/web/vite.config.ts +33 -0
  161. package/dist/index.js +1 -12
  162. package/dist/style.css +1 -1
  163. package/lib/index.js +2070 -2077
  164. package/package.json +15 -2
  165. package/readme.md +0 -22
  166. package/src/bootstrap.ts +71 -0
  167. package/src/cache.ts +319 -0
  168. package/src/config.ts +23 -35
  169. package/src/database.ts +650 -0
  170. package/src/index.ts +27 -59
  171. package/src/media.ts +302 -0
  172. package/src/recorder.ts +175 -0
  173. package/src/self-message.ts +456 -0
  174. package/src/server.d.ts +26 -0
  175. package/src/types.ts +108 -48
  176. package/src/web.ts +712 -0
  177. package/client/vue/chat-logic.ts +0 -683
  178. package/client/vue/composables/useChatActions.ts +0 -61
  179. package/client/vue/composables/useChatData.ts +0 -185
  180. package/client/vue/composables/useImageCache.ts +0 -281
  181. package/client/vue/composables/useVideoCache.ts +0 -125
  182. package/client/vue/types.ts +0 -72
  183. package/lib/api-handlers.d.ts +0 -25
  184. package/lib/config.d.ts +0 -14
  185. package/lib/file-manager.d.ts +0 -124
  186. package/lib/index.d.ts +0 -17
  187. package/lib/logger.d.ts +0 -9
  188. package/lib/message-handler.d.ts +0 -35
  189. package/lib/types.d.ts +0 -57
  190. package/lib/utils.d.ts +0 -18
  191. package/src/api-handlers.ts +0 -646
  192. package/src/file-manager.ts +0 -1436
  193. package/src/message-handler.ts +0 -507
  194. 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 { Utils } from './utils'
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 = true
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
- 开启后,即可在koishi控制台操作机器人收发消息啦
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 fileManager = new FileManager(ctx, config, pluginLogger)
43
- await fileManager.initialize()
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 metadata = await fileManager.readMetadataOnly()
46
+ const media = new MediaManager(ctx, config, database, pluginLogger)
47
+ media.start()
50
48
 
51
- pluginLogger.logInfo('插件加载完成,元数据统计:', {
52
- 机器人数量: Object.keys(metadata.bots).length,
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.on('before-send', (session) => {
66
- if (utils.isPlatformBlocked(session.platform || 'unknown')) return
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
- apiHandlers.registerApiHandlers()
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
- cleanupDelayTimer?.()
96
- cleanupInterval?.()
97
- messageHandler.dispose()
98
- void fileManager.dispose()
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
+ }
@@ -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
+ }