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.
Files changed (237) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/dist/assets/AppIcon-DIku6-h0.png +0 -0
  3. package/client/web/dist/assets/AppIconDark-BYymN71t.png +0 -0
  4. package/client/web/dist/assets/CapacitorAppIcon-CSZwNhvi.png +0 -0
  5. package/client/web/dist/assets/CapacitorAppIconDark-BMvMOzsg.png +0 -0
  6. package/client/web/dist/assets/Chat-CTdyeaX2.css +1 -0
  7. package/client/web/dist/assets/Chat-DkZvD7d8.js +28 -0
  8. package/client/web/dist/assets/MealHungryPan-Beos3lRI.js +1 -0
  9. package/client/web/dist/assets/MsgBody-aZ4_aija.js +1 -0
  10. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CPZjRLp6.js +67 -0
  11. package/client/web/dist/assets/NapcatAppIcon-C7VOAkW9.png +0 -0
  12. package/client/web/dist/assets/Vue-lEhHouxB.png +0 -0
  13. package/client/web/dist/assets/append_darwin-CqBNU62J.css +1 -0
  14. package/client/web/dist/assets/append_full_vibrancy-j0D2LM9m.css +1 -0
  15. package/client/web/dist/assets/append_linux-uhxeBqeC.css +1 -0
  16. package/client/web/dist/assets/append_linux_vibrancy-COiZq8MS.css +1 -0
  17. package/client/web/dist/assets/append_napcat-DV8CAyAd.css +1 -0
  18. package/client/web/dist/assets/append_new-407Lo5_V.css +1 -0
  19. package/client/web/dist/assets/append_vibrancy-6FHctkOY.css +1 -0
  20. package/client/web/dist/assets/append_win32-srmD-78y.css +1 -0
  21. package/client/web/dist/assets/cakes-CxTVKdnK.svg +189 -0
  22. package/client/web/dist/assets/core-DhEqZVGG.js +1 -0
  23. package/client/web/dist/assets/event-Cadp6m6J.js +1 -0
  24. package/client/web/dist/assets/index-CPTXiemn.js +4045 -0
  25. package/client/web/dist/assets/index-Cg_esKB0.js +1 -0
  26. package/client/web/dist/assets/index-Cln012sP.css +1 -0
  27. package/client/web/dist/assets//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223-CJKIKBn3.ttf +0 -0
  28. package/client/web/dist/assets//346/234/237/345/276/205-C90_OY60.png +0 -0
  29. package/client/web/dist/bcui/css/color-dark.css +35 -0
  30. package/client/web/dist/bcui/css/color-light.css +35 -0
  31. package/client/web/dist/bcui/css/style.css +468 -0
  32. package/client/web/dist/bcui/js/main.js +193 -0
  33. package/client/web/dist/css/append-dark.css +13 -0
  34. package/client/web/dist/css/append-light.css +13 -0
  35. package/client/web/dist/css/append-napcat-dark.css +14 -0
  36. package/client/web/dist/css/append-napcat-light.css +14 -0
  37. package/client/web/dist/favicon.ico +0 -0
  38. package/client/web/dist/img/icons/icon-for-fuck-apple.png +0 -0
  39. package/client/web/dist/img/icons/icon-maskable.png +0 -0
  40. package/client/web/dist/img/icons/icon.png +0 -0
  41. package/client/web/dist/img/icons/icon.svg +10 -0
  42. package/client/web/dist/index.html +22 -0
  43. package/client/web/dist/robots.txt +2 -0
  44. package/client/web/dist/sw.js +58 -0
  45. package/client/web/env.d.ts +56 -0
  46. package/client/web/index.html +21 -0
  47. package/client/web/package-lock.json +3419 -0
  48. package/client/web/package.json +62 -0
  49. package/client/web/public/bcui/css/color-dark.css +35 -0
  50. package/client/web/public/bcui/css/color-light.css +35 -0
  51. package/client/web/public/bcui/css/style.css +468 -0
  52. package/client/web/public/bcui/js/main.js +193 -0
  53. package/client/web/public/css/append-dark.css +13 -0
  54. package/client/web/public/css/append-light.css +13 -0
  55. package/client/web/public/css/append-napcat-dark.css +14 -0
  56. package/client/web/public/css/append-napcat-light.css +14 -0
  57. package/client/web/public/favicon.ico +0 -0
  58. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  59. package/client/web/public/img/icons/icon-maskable.png +0 -0
  60. package/client/web/public/img/icons/icon.png +0 -0
  61. package/client/web/public/img/icons/icon.svg +10 -0
  62. package/client/web/public/robots.txt +2 -0
  63. package/client/web/public/sw.js +58 -0
  64. package/client/web/scripts/copy-bcui.mjs +17 -0
  65. package/client/web/src/App.vue +1127 -0
  66. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  67. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  68. package/client/web/src/assets/css/append/append_linux.css +125 -0
  69. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  70. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  71. package/client/web/src/assets/css/append/append_new.css +843 -0
  72. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  73. package/client/web/src/assets/css/append/append_win32.css +109 -0
  74. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  75. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  76. package/client/web/src/assets/css/chat.css +2104 -0
  77. package/client/web/src/assets/css/msg.css +941 -0
  78. package/client/web/src/assets/css/options.css +811 -0
  79. package/client/web/src/assets/css/sys_notice.css +62 -0
  80. package/client/web/src/assets/css/view.css +2105 -0
  81. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  82. package/client/web/src/assets/img/Vue.png +0 -0
  83. package/client/web/src/assets/img/cakes.svg +189 -0
  84. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  85. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  86. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  87. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  88. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  89. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  90. package/client/web/src/assets/img/icons/exit.png +0 -0
  91. package/client/web/src/assets/img/icons/pen.png +0 -0
  92. package/client/web/src/assets/img/stars.svg +89 -0
  93. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  94. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  95. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  96. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  97. package/client/web/src/assets/logo.png +0 -0
  98. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  99. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  100. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  101. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  102. package/client/web/src/assets/qed.txt +1 -0
  103. package/client/web/src/components/AboutPan.vue +197 -0
  104. package/client/web/src/components/BulletinBody.vue +129 -0
  105. package/client/web/src/components/DepPan.vue +310 -0
  106. package/client/web/src/components/EmojiFace.vue +32 -0
  107. package/client/web/src/components/FacePan.vue +461 -0
  108. package/client/web/src/components/FileBody.vue +129 -0
  109. package/client/web/src/components/FileManager.vue +532 -0
  110. package/client/web/src/components/FriendBody.vue +54 -0
  111. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  112. package/client/web/src/components/History.vue +49 -0
  113. package/client/web/src/components/LazyLottie.vue +82 -0
  114. package/client/web/src/components/MergePan.vue +127 -0
  115. package/client/web/src/components/MsgBody.vue +1478 -0
  116. package/client/web/src/components/MusicPlayer.vue +549 -0
  117. package/client/web/src/components/NoticeBody.vue +168 -0
  118. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  119. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  120. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  121. package/client/web/src/components/TinySessionBody.vue +51 -0
  122. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  123. package/client/web/src/components/UpdatePan.vue +263 -0
  124. package/client/web/src/components/ViewerCom.vue +1360 -0
  125. package/client/web/src/components/VoiceMsg.vue +626 -0
  126. package/client/web/src/components/WelPan.vue +894 -0
  127. package/client/web/src/components/msg-body/.gitkeep +0 -0
  128. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  129. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  130. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  131. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  132. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  133. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  134. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  135. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  136. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  137. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  138. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  139. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  140. package/client/web/src/components/notice-component/.gitkeep +0 -0
  141. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  142. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  143. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  144. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  145. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  146. package/client/web/src/function/base.ts +238 -0
  147. package/client/web/src/function/connect.ts +2042 -0
  148. package/client/web/src/function/debug.ts +10 -0
  149. package/client/web/src/function/elements/information.ts +141 -0
  150. package/client/web/src/function/elements/system.ts +101 -0
  151. package/client/web/src/function/elements/vueComp.ts +62 -0
  152. package/client/web/src/function/favicon.ts +76 -0
  153. package/client/web/src/function/input.ts +69 -0
  154. package/client/web/src/function/model/emoji.ts +127 -0
  155. package/client/web/src/function/model/img.ts +150 -0
  156. package/client/web/src/function/model/msg-body.ts +51 -0
  157. package/client/web/src/function/msg.ts +2674 -0
  158. package/client/web/src/function/notify.ts +225 -0
  159. package/client/web/src/function/option.ts +749 -0
  160. package/client/web/src/function/satori-model.ts +818 -0
  161. package/client/web/src/function/satori.ts +325 -0
  162. package/client/web/src/function/sender.ts +178 -0
  163. package/client/web/src/function/tooltip.ts +49 -0
  164. package/client/web/src/function/utils/appUtil.ts +2288 -0
  165. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  166. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  167. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  168. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  169. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  170. package/client/web/src/function/utils/pinyin.ts +141 -0
  171. package/client/web/src/function/utils/recordUtil.ts +187 -0
  172. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  173. package/client/web/src/function/utils/systemUtil.ts +664 -0
  174. package/client/web/src/main.ts +91 -0
  175. package/client/web/src/pages/Chat.vue +3453 -0
  176. package/client/web/src/pages/Friends.vue +636 -0
  177. package/client/web/src/pages/Info.vue +717 -0
  178. package/client/web/src/pages/Messages.vue +875 -0
  179. package/client/web/src/pages/Options.vue +82 -0
  180. package/client/web/src/pages/Qzone.vue +1178 -0
  181. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  182. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  183. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  184. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  185. package/client/web/src/pages/options/OptAccount.vue +107 -0
  186. package/client/web/src/pages/options/OptAddon.vue +185 -0
  187. package/client/web/src/pages/options/OptDev.vue +704 -0
  188. package/client/web/src/pages/options/OptFunction.vue +403 -0
  189. package/client/web/src/pages/options/OptInfo.vue +165 -0
  190. package/client/web/src/pages/options/OptView.vue +846 -0
  191. package/client/web/src/runtime/backend.ts +310 -0
  192. package/client/web/src/state/auth.ts +14 -0
  193. package/client/web/src/state/chat.ts +36 -0
  194. package/client/web/src/state/connection.ts +20 -0
  195. package/client/web/src/state/contact.ts +37 -0
  196. package/client/web/src/state/qzone.ts +18 -0
  197. package/client/web/src/state/sessionHistory.ts +25 -0
  198. package/client/web/src/state/settings.ts +18 -0
  199. package/client/web/src/state/sticker.ts +10 -0
  200. package/client/web/src/state/ui.ts +56 -0
  201. package/client/web/tsconfig.json +27 -0
  202. package/client/web/tsconfig.tsbuildinfo +1 -0
  203. package/client/web/vite.config.ts +33 -0
  204. package/dist/index.js +1 -12
  205. package/dist/style.css +1 -1
  206. package/lib/index.js +2070 -2077
  207. package/package.json +15 -2
  208. package/readme.md +0 -22
  209. package/src/bootstrap.ts +71 -0
  210. package/src/cache.ts +319 -0
  211. package/src/config.ts +23 -35
  212. package/src/database.ts +650 -0
  213. package/src/index.ts +27 -59
  214. package/src/media.ts +302 -0
  215. package/src/recorder.ts +175 -0
  216. package/src/self-message.ts +456 -0
  217. package/src/server.d.ts +26 -0
  218. package/src/types.ts +108 -48
  219. package/src/web.ts +712 -0
  220. package/client/vue/chat-logic.ts +0 -683
  221. package/client/vue/composables/useChatActions.ts +0 -61
  222. package/client/vue/composables/useChatData.ts +0 -185
  223. package/client/vue/composables/useImageCache.ts +0 -281
  224. package/client/vue/composables/useVideoCache.ts +0 -125
  225. package/client/vue/types.ts +0 -72
  226. package/lib/api-handlers.d.ts +0 -25
  227. package/lib/config.d.ts +0 -14
  228. package/lib/file-manager.d.ts +0 -124
  229. package/lib/index.d.ts +0 -17
  230. package/lib/logger.d.ts +0 -9
  231. package/lib/message-handler.d.ts +0 -35
  232. package/lib/types.d.ts +0 -57
  233. package/lib/utils.d.ts +0 -18
  234. package/src/api-handlers.ts +0 -646
  235. package/src/file-manager.ts +0 -1436
  236. package/src/message-handler.ts +0 -507
  237. package/src/utils.ts +0 -189
@@ -1,1436 +0,0 @@
1
- import { BotInfo, ChannelInfo, ChatData, MessageInfo } from './types'
2
- import { Context } from 'koishi'
3
- import { Config } from './config'
4
- import { Utils } from './utils'
5
- import { PluginLogger } from './logger'
6
-
7
- import path from 'node:path'
8
- import { promises as fs } from 'node:fs'
9
-
10
- interface ChannelChunkMeta {
11
- id: number
12
- fileName: string
13
- messageCount: number
14
- }
15
-
16
- interface ChannelIndexData {
17
- version: 1
18
- channelId: string
19
- totalMessages: number
20
- nextChunkId: number
21
- chunks: ChannelChunkMeta[]
22
- }
23
-
24
- interface StoredChannelEntry {
25
- selfId: string
26
- channelId: string
27
- channelKey: string
28
- channelDirPath: string
29
- indexFilePath: string
30
- }
31
-
32
- export class FileManager {
33
- private storageBaseDir: string
34
- private chatHistoryDir: string // 聊天记录根目录
35
- private metadataFilePath: string // 元数据文件路径(存储bots、channels、pinned等信息)
36
- private utils: Utils
37
-
38
- private memoryCache: ChatData = this.createEmptyChatData()
39
-
40
- private channelMessagesCache: Map<string, MessageInfo[]> = new Map()
41
-
42
- private recentMessageIdsCache: Map<string, string[]> = new Map()
43
-
44
- private pendingBotMessageIds: Map<string, string[]> = new Map()
45
-
46
- private messageChunkLocationCache: Map<string, Map<string, string>> = new Map()
47
-
48
- private dirtyChannelKeys: Set<string> = new Set()
49
-
50
- private pendingMessages: Map<string, MessageInfo[]> = new Map() // 按channelKey分组的待写入消息
51
-
52
- private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
53
-
54
- private metadataLoadPromise: Promise<void> | null = null
55
-
56
- private channelLoadPromises: Map<string, Promise<MessageInfo[]>> = new Map()
57
-
58
- private writeQueue: Promise<void> = Promise.resolve()
59
-
60
- private disposed = false
61
-
62
- private readonly WRITE_DEBOUNCE_MS = 1000
63
-
64
- private readonly RECENT_MESSAGE_ID_CACHE_SIZE = 200
65
-
66
- constructor(
67
- private ctx: Context,
68
- private config: Config,
69
- private logger: PluginLogger
70
- ) {
71
- const baseDir = path.resolve(ctx.baseDir, 'data', 'chat-patch')
72
- this.storageBaseDir = path.join(baseDir, 'v2')
73
- this.chatHistoryDir = path.join(this.storageBaseDir, 'chat-history')
74
- this.metadataFilePath = path.join(this.storageBaseDir, 'metadata.json')
75
- this.utils = new Utils(config, ctx)
76
-
77
- // 异步清理旧版本数据,避免阻塞启动。
78
- this.ctx.setTimeout(() => {
79
- void this.cleanupLegacyStorage(baseDir)
80
- }, 0)
81
- }
82
-
83
- async initialize() {
84
- await this.ensureMetadataLoaded()
85
- }
86
-
87
- readChatDataFromFile(): ChatData {
88
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
89
- return this.memoryCache
90
- }
91
-
92
- getCachedChannelInfo(selfId: string, channelId: string): ChannelInfo | undefined {
93
- return this.memoryCache.channels[selfId]?.[channelId]
94
- }
95
-
96
- async readMetadataOnly(): Promise<Omit<ChatData, 'messages'>> {
97
- await this.ensureMetadataLoaded()
98
- const { messages, ...metadata } = this.memoryCache
99
- return { ...metadata }
100
- }
101
-
102
- async upsertBotInfo(botInfo: BotInfo) {
103
- await this.ensureMetadataLoaded()
104
- const current = this.memoryCache.bots[botInfo.selfId]
105
- if (current && this.isSameBotInfo(current, botInfo)) {
106
- return
107
- }
108
-
109
- this.memoryCache.bots[botInfo.selfId] = botInfo
110
- this.scheduleMetadataWrite()
111
- }
112
-
113
- async upsertChannelInfo(selfId: string, channelId: string, channelInfo: ChannelInfo) {
114
- await this.ensureMetadataLoaded()
115
-
116
- if (!this.memoryCache.channels[selfId]) {
117
- this.memoryCache.channels[selfId] = {}
118
- }
119
-
120
- const current = this.memoryCache.channels[selfId][channelId]
121
- if (current && this.isSameChannelInfo(current, channelInfo)) {
122
- return
123
- }
124
-
125
- this.memoryCache.channels[selfId][channelId] = channelInfo
126
- this.scheduleMetadataWrite()
127
- }
128
-
129
- async setPinnedBots(pinnedBots: string[]) {
130
- await this.ensureMetadataLoaded()
131
- this.memoryCache.pinnedBots = [...pinnedBots]
132
- this.scheduleMetadataWrite()
133
- }
134
-
135
- async setPinnedChannels(pinnedChannels: string[]) {
136
- await this.ensureMetadataLoaded()
137
- this.memoryCache.pinnedChannels = [...pinnedChannels]
138
- this.scheduleMetadataWrite()
139
- }
140
-
141
- // 清理旧版本数据目录
142
- private async cleanupLegacyStorage(baseDir: string) {
143
- const oldFiles = ['chat-data.json', 'data.json', 'messages.json']
144
- for (const fileName of oldFiles) {
145
- const filePath = path.join(baseDir, fileName)
146
- try {
147
- await fs.unlink(filePath)
148
- this.logger.logInfo(`已删除旧版本数据文件: ${fileName}`)
149
- } catch (error) {
150
- if (!this.isFileMissingError(error)) {
151
- this.logger.warn(`删除旧版本数据文件失败: ${fileName}`, error)
152
- }
153
- }
154
- }
155
-
156
- const legacyPaths = [
157
- path.join(baseDir, 'metadata.json'),
158
- path.join(baseDir, 'chat-history')
159
- ]
160
-
161
- for (const legacyPath of legacyPaths) {
162
- try {
163
- await fs.rm(legacyPath, { recursive: true, force: true })
164
- this.logger.logInfo(`已清理旧版数据路径: ${legacyPath}`)
165
- } catch (error) {
166
- this.logger.warn(`清理旧版数据路径失败: ${legacyPath}`, error)
167
- }
168
- }
169
- }
170
-
171
- // 确保目录存在
172
- private async ensureDir(dirPath: string) {
173
- await fs.mkdir(dirPath, { recursive: true })
174
- }
175
-
176
- // 读取单个频道的消息(公共方法)
177
- async readChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
178
- await this.ensureMetadataLoaded()
179
-
180
- const channelKey = `${selfId}:${channelId}`
181
- const cached = this.getCachedChannelMessages(channelKey)
182
- if (cached) {
183
- return cached
184
- }
185
-
186
- const existingPromise = this.channelLoadPromises.get(channelKey)
187
- if (existingPromise) {
188
- return existingPromise
189
- }
190
-
191
- const loadPromise = this.loadChannelMessages(selfId, channelId)
192
- this.channelLoadPromises.set(channelKey, loadPromise)
193
-
194
- try {
195
- return await loadPromise
196
- } finally {
197
- this.channelLoadPromises.delete(channelKey)
198
- }
199
- }
200
-
201
- async readChannelMessagesPage(selfId: string, channelId: string, limit: number, offset = 0): Promise<{ messages: MessageInfo[], total: number }> {
202
- await this.ensureMetadataLoaded()
203
-
204
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
205
- if (limit <= 0 || offset >= indexData.totalMessages) {
206
- return {
207
- messages: [],
208
- total: indexData.totalMessages
209
- }
210
- }
211
-
212
- const entry = this.createStoredChannelEntry(selfId, channelId)
213
- const collected: MessageInfo[] = []
214
- let skipped = 0
215
-
216
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
217
- const chunk = indexData.chunks[chunkIndex]
218
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
219
-
220
- for (let messageIndex = chunkMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
221
- if (skipped < offset) {
222
- skipped += 1
223
- continue
224
- }
225
-
226
- collected.push(chunkMessages[messageIndex])
227
- if (collected.length >= limit) {
228
- return {
229
- messages: collected.reverse(),
230
- total: indexData.totalMessages
231
- }
232
- }
233
- }
234
- }
235
-
236
- return {
237
- messages: collected.reverse(),
238
- total: indexData.totalMessages
239
- }
240
- }
241
-
242
- async findChannelMessageById(selfId: string, channelId: string, messageId: string): Promise<MessageInfo | null> {
243
- await this.ensureMetadataLoaded()
244
-
245
- const channelKey = `${selfId}:${channelId}`
246
- const cachedMessages = this.peekCachedChannelMessages(channelKey)
247
- const cachedMatched = cachedMessages?.find((message) => message.id === messageId)
248
- if (cachedMatched) {
249
- return cachedMatched
250
- }
251
-
252
- const entry = this.createStoredChannelEntry(selfId, channelId)
253
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
254
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
255
- const chunk = indexData.chunks[chunkIndex]
256
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
257
- const matched = messages.find((message) => message.id === messageId)
258
- if (matched) {
259
- return matched
260
- }
261
- }
262
-
263
- return null
264
- }
265
-
266
- // 读取元数据(bots、channels、pinned等)
267
- private async readMetadata(): Promise<Omit<ChatData, 'messages'>> {
268
- try {
269
- const jsonData = await fs.readFile(this.metadataFilePath, 'utf8')
270
- const data = JSON.parse(jsonData)
271
- return {
272
- bots: data.bots || {},
273
- channels: data.channels || {},
274
- pinnedBots: data.pinnedBots || [],
275
- pinnedChannels: data.pinnedChannels || [],
276
- lastSaveTime: data.lastSaveTime
277
- }
278
- } catch (error) {
279
- if (this.isFileMissingError(error)) {
280
- return {
281
- bots: {},
282
- channels: {},
283
- pinnedBots: [],
284
- pinnedChannels: []
285
- }
286
- }
287
-
288
- this.logger.error('读取元数据失败:', error)
289
- return {
290
- bots: {},
291
- channels: {},
292
- pinnedBots: [],
293
- pinnedChannels: []
294
- }
295
- }
296
- }
297
-
298
- // 写入元数据
299
- private async writeMetadata(metadata: Omit<ChatData, 'messages'>) {
300
- try {
301
- await this.ensureDir(path.dirname(this.metadataFilePath))
302
- const dataToWrite = {
303
- ...metadata,
304
- lastSaveTime: Date.now()
305
- }
306
- const jsonData = JSON.stringify(dataToWrite, null, 2)
307
- await this.atomicWriteTextFile(this.metadataFilePath, jsonData)
308
- } catch (error) {
309
- this.logger.error('写入元数据失败:', error)
310
- }
311
- }
312
-
313
- // 为特定频道安排写入
314
- private scheduleWrite(channelKey: string) {
315
- // 取消之前的定时器
316
- const existingTimer = this.writeTimers.get(channelKey)
317
- if (existingTimer) {
318
- existingTimer()
319
- }
320
-
321
- // 创建新的定时器
322
- const timer = this.ctx.setTimeout(() => {
323
- this.writeTimers.delete(channelKey)
324
- void this.flushPendingMessages(channelKey)
325
- }, this.WRITE_DEBOUNCE_MS)
326
-
327
- this.writeTimers.set(channelKey, timer)
328
- }
329
-
330
- // 刷新特定频道的待写入消息
331
- private async flushPendingMessages(channelKey: string) {
332
- const messagesToWrite = this.pendingMessages.get(channelKey)
333
- if (!messagesToWrite || messagesToWrite.length === 0) return
334
-
335
- this.pendingMessages.delete(channelKey)
336
-
337
- const cachedMessages = this.peekCachedChannelMessages(channelKey)
338
- const [selfId, channelId] = channelKey.split(':')
339
-
340
- if (!selfId || !channelId) return
341
-
342
- const uniqueMessages = this.deduplicateMessages(messagesToWrite)
343
- if (!uniqueMessages.length) {
344
- return
345
- }
346
-
347
- if (cachedMessages) {
348
- const nextMessages = this.mergeChannelMessages(cachedMessages, uniqueMessages)
349
- this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
350
- }
351
-
352
- this.rememberRecentMessageIds(channelKey, uniqueMessages.map((message) => message.id))
353
-
354
- // 只向最后一个 chunk 追加,避免整频道重写。
355
- this.enqueueWrite(async () => {
356
- await this.appendMessagesToChannel(selfId, channelId, uniqueMessages)
357
- this.logger.logInfo(`批量写入 ${uniqueMessages.length} 条消息到频道 ${channelKey}`)
358
- })
359
- }
360
-
361
- cleanExcessMessages(data: ChatData): ChatData {
362
- let cleanedCount = 0
363
- const cleanedMessages: Record<string, MessageInfo[]> = {}
364
-
365
- for (const [channelKey, messages] of Object.entries(data.messages)) {
366
- if (messages.length > this.config.maxMessagesPerChannel) {
367
- const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
368
- const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
369
- cleanedCount += messages.length - keptMessages.length
370
- cleanedMessages[channelKey] = keptMessages
371
- this.logger.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
372
- } else {
373
- cleanedMessages[channelKey] = messages
374
- }
375
- }
376
-
377
- if (cleanedCount > 0) {
378
- this.logger.logInfo('总共清理超量消息:', cleanedCount, '条')
379
- }
380
-
381
- return {
382
- ...data,
383
- messages: cleanedMessages
384
- }
385
- }
386
-
387
- async addMessageToFile(messageInfo: MessageInfo) {
388
- await this.ensureMetadataLoaded()
389
- const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
390
-
391
- if (!messageInfo.timestamp) {
392
- messageInfo.timestamp = Date.now()
393
- }
394
-
395
- const cleanedMessageInfo = await this.utils.cleanBase64ContentAsync(messageInfo, false)
396
- const pendingMessages = this.pendingMessages.get(channelKey) || []
397
- if (pendingMessages.some((message) => message.id === cleanedMessageInfo.id)) {
398
- return
399
- }
400
-
401
- const cachedMessages = this.peekCachedChannelMessages(channelKey)
402
- if (cachedMessages?.some((message) => message.id === cleanedMessageInfo.id)) {
403
- return
404
- }
405
-
406
- if (!cachedMessages) {
407
- if (this.hasRecentMessageId(channelKey, cleanedMessageInfo.id)) {
408
- return
409
- }
410
-
411
- const existsInStorage = await this.channelMessageExists(messageInfo.selfId, messageInfo.channelId, cleanedMessageInfo.id)
412
- if (existsInStorage) {
413
- return
414
- }
415
- }
416
-
417
- pendingMessages.push(cleanedMessageInfo)
418
- this.pendingMessages.set(channelKey, pendingMessages)
419
- this.rememberRecentMessageIds(channelKey, [cleanedMessageInfo.id])
420
-
421
- if (cleanedMessageInfo.type === 'bot' && cleanedMessageInfo.sending) {
422
- this.registerPendingBotMessage(channelKey, cleanedMessageInfo.id)
423
- }
424
-
425
- if (cachedMessages) {
426
- const nextMessages = this.mergeChannelMessages(cachedMessages, [cleanedMessageInfo])
427
- this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
428
- }
429
-
430
- // 安排写入
431
- this.scheduleWrite(channelKey)
432
- }
433
-
434
- async cleanupExcessMessagesInStorage() {
435
- let cleanedCount = 0
436
-
437
- for (const channelKey of [...this.dirtyChannelKeys]) {
438
- const [selfId, channelId] = channelKey.split(':')
439
- if (!selfId || !channelId) {
440
- this.dirtyChannelKeys.delete(channelKey)
441
- continue
442
- }
443
-
444
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
445
- if (indexData.totalMessages <= this.config.maxMessagesPerChannel) {
446
- this.dirtyChannelKeys.delete(channelKey)
447
- continue
448
- }
449
-
450
- const removedCount = await this.trimChannelToLimit(selfId, channelId, indexData, this.config.maxMessagesPerChannel)
451
- if (!removedCount) {
452
- continue
453
- }
454
-
455
- cleanedCount += removedCount
456
- if (this.peekCachedChannelMessages(channelKey)) {
457
- this.setCachedChannelMessages(channelKey, await this.loadChannelMessagesNoCache(selfId, channelId))
458
- }
459
- this.dirtyChannelKeys.delete(channelKey)
460
- this.logger.logInfo(`频道 ${channelKey} 清理了 ${removedCount} 条旧消息,保留最新 ${indexData.totalMessages} 条`)
461
- }
462
-
463
- if (cleanedCount > 0) {
464
- this.logger.logInfo('定期清理完成,清理了', cleanedCount, '条超量消息')
465
- }
466
- }
467
-
468
- async getAllChannelMessageCounts(): Promise<Record<string, number>> {
469
- const counts: Record<string, number> = {}
470
-
471
- await this.scanStoredChannels(async (entry) => {
472
- counts[entry.channelKey] = await this.countChannelMessages(entry.selfId, entry.channelId)
473
- })
474
-
475
- return counts
476
- }
477
-
478
- async deleteChannelData(selfId: string, channelId: string) {
479
- await this.ensureMetadataLoaded()
480
-
481
- const channelKey = `${selfId}:${channelId}`
482
- const deletedMessages = await this.countChannelMessages(selfId, channelId)
483
-
484
- this.deleteCachedChannelMessages(channelKey)
485
- this.deleteRecentMessageIds(channelKey)
486
- this.deletePendingBotMessages(channelKey)
487
- this.deleteMessageChunkLocations(channelKey)
488
- this.dirtyChannelKeys.delete(channelKey)
489
- this.pendingMessages.delete(channelKey)
490
-
491
- const timer = this.writeTimers.get(channelKey)
492
- if (timer) {
493
- timer()
494
- this.writeTimers.delete(channelKey)
495
- }
496
-
497
- if (this.memoryCache.channels[selfId]?.[channelId]) {
498
- delete this.memoryCache.channels[selfId][channelId]
499
- if (!Object.keys(this.memoryCache.channels[selfId]).length) {
500
- delete this.memoryCache.channels[selfId]
501
- }
502
- }
503
-
504
- this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => item !== channelKey)
505
-
506
- await this.removeChannelStorage(selfId, channelId)
507
-
508
- this.scheduleMetadataWrite()
509
-
510
- return { deletedMessages }
511
- }
512
-
513
- async deleteBotData(selfId: string) {
514
- await this.ensureMetadataLoaded()
515
-
516
- const botDir = path.join(this.chatHistoryDir, selfId)
517
- const channels = await this.listBotChannels(selfId)
518
- let deletedMessages = 0
519
-
520
- for (const entry of channels) {
521
- deletedMessages += await this.countChannelMessages(entry.selfId, entry.channelId)
522
- this.deleteCachedChannelMessages(entry.channelKey)
523
- this.deleteRecentMessageIds(entry.channelKey)
524
- this.deletePendingBotMessages(entry.channelKey)
525
- this.deleteMessageChunkLocations(entry.channelKey)
526
- this.dirtyChannelKeys.delete(entry.channelKey)
527
- this.pendingMessages.delete(entry.channelKey)
528
-
529
- const timer = this.writeTimers.get(entry.channelKey)
530
- if (timer) {
531
- timer()
532
- this.writeTimers.delete(entry.channelKey)
533
- }
534
- }
535
-
536
- const deletedChannels = channels.length
537
-
538
- delete this.memoryCache.bots[selfId]
539
- delete this.memoryCache.channels[selfId]
540
- this.memoryCache.pinnedBots = this.memoryCache.pinnedBots.filter((item) => item !== selfId)
541
- this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => !item.startsWith(`${selfId}:`))
542
-
543
- try {
544
- await fs.rm(botDir, { recursive: true, force: true })
545
- } catch (error) {
546
- this.logger.error(`删除机器人目录失败 [${selfId}]:`, error)
547
- }
548
-
549
- this.scheduleMetadataWrite()
550
-
551
- return {
552
- deletedChannels,
553
- deletedMessages
554
- }
555
- }
556
-
557
- async updateUserProfileInBotData(selfId: string, userId: string, userName?: string, avatar?: string) {
558
- await this.ensureMetadataLoaded()
559
-
560
- let changed = false
561
-
562
- const botChannels = this.memoryCache.channels[selfId] || {}
563
- const possibleChannelIds = [
564
- userId,
565
- `private:${userId}`,
566
- `direct:${userId}`
567
- ]
568
-
569
- for (const channelId of possibleChannelIds) {
570
- const channel = botChannels[channelId]
571
- if (!channel || !channel.isDirect || !userName) {
572
- continue
573
- }
574
-
575
- const newName = `私聊(${userName})`
576
- if (channel.name !== newName) {
577
- channel.name = newName
578
- changed = true
579
- }
580
- }
581
-
582
- const channels = await this.listBotChannels(selfId)
583
- for (const entry of channels) {
584
- const entryChanged = await this.updateUserProfileInChannel(entry.selfId, entry.channelId, userId, userName, avatar)
585
- if (!entryChanged) {
586
- continue
587
- }
588
-
589
- if (this.peekCachedChannelMessages(entry.channelKey)) {
590
- this.setCachedChannelMessages(entry.channelKey, await this.loadChannelMessagesNoCache(entry.selfId, entry.channelId))
591
- }
592
- changed = true
593
- }
594
-
595
- if (changed) {
596
- this.scheduleMetadataWrite()
597
- }
598
-
599
- return changed
600
- }
601
-
602
- async markLatestBotMessageAsSent(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
603
- const channelKey = `${selfId}:${channelId}`
604
- const tempMessageId = this.peekLatestPendingBotMessageId(channelKey)
605
-
606
- let matched: MessageInfo | undefined
607
- if (tempMessageId) {
608
- matched = await this.findAndUpdateBotMessageByTempId(selfId, channelId, tempMessageId, realId)
609
- this.consumePendingBotMessageId(channelKey, tempMessageId)
610
- }
611
-
612
- if (!matched) {
613
- matched = await this.findAndUpdateLatestBotMessage(selfId, channelId, realId)
614
- }
615
-
616
- if (!matched) {
617
- return undefined
618
- }
619
-
620
- const cachedMessages = this.peekCachedChannelMessages(channelKey)
621
- if (cachedMessages) {
622
- const cachedMatched = cachedMessages.find((message) => message.id === matched?.id)
623
- if (cachedMatched) {
624
- cachedMatched.realId = realId
625
- cachedMatched.sending = false
626
- this.setCachedChannelMessages(channelKey, cachedMessages)
627
- }
628
- }
629
-
630
- return matched
631
- }
632
-
633
- async dispose() {
634
- this.disposed = true
635
-
636
- // 取消所有定时器
637
- for (const [channelKey, timer] of this.writeTimers.entries()) {
638
- timer()
639
- await this.flushPendingMessages(channelKey)
640
- }
641
- this.writeTimers.clear()
642
-
643
- await this.writeQueue
644
- await this.utils.dispose()
645
- }
646
-
647
- private createEmptyChatData(): ChatData {
648
- return {
649
- bots: {},
650
- channels: {},
651
- messages: {},
652
- pinnedBots: [],
653
- pinnedChannels: []
654
- }
655
- }
656
-
657
- private async ensureMetadataLoaded() {
658
- if (!this.metadataLoadPromise) {
659
- this.metadataLoadPromise = this.loadMetadataIntoCache()
660
- }
661
-
662
- await this.metadataLoadPromise
663
- }
664
-
665
- private async loadMetadataIntoCache() {
666
- const metadata = await this.readMetadata()
667
- this.memoryCache = {
668
- ...metadata,
669
- messages: this.getChannelMessagesCacheSnapshot()
670
- }
671
- }
672
-
673
- private async loadChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
674
- const messages = await this.loadChannelMessagesNoCache(selfId, channelId)
675
- this.setCachedChannelMessages(`${selfId}:${channelId}`, messages)
676
- return messages
677
- }
678
-
679
- private async loadChannelMessagesNoCache(selfId: string, channelId: string): Promise<MessageInfo[]> {
680
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
681
- const channelDirPath = this.getChannelDirPath(selfId, channelId)
682
- const messages: MessageInfo[] = []
683
- const channelKey = `${selfId}:${channelId}`
684
-
685
- for (const chunk of indexData.chunks) {
686
- const chunkMessages = await this.readChunkMessages(channelDirPath, chunk.fileName)
687
- messages.push(...chunkMessages)
688
- this.rememberMessageChunkLocation(channelKey, chunk.fileName, chunkMessages.map((message) => message.id))
689
- }
690
-
691
- this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
692
-
693
- return messages
694
- }
695
-
696
- private scheduleMetadataWrite() {
697
- this.enqueueWrite(async () => {
698
- const { messages, ...metadata } = this.readChatDataFromFile()
699
- await this.writeMetadata(metadata)
700
- })
701
- }
702
-
703
- private enqueueWrite(task: () => Promise<void>) {
704
- if (this.disposed) {
705
- return
706
- }
707
-
708
- this.writeQueue = this.writeQueue
709
- .then(task)
710
- .catch((error) => {
711
- this.logger.error('写入任务失败:', error)
712
- })
713
- }
714
-
715
- private async listStoredChannels(): Promise<StoredChannelEntry[]> {
716
- const result: StoredChannelEntry[] = []
717
-
718
- await this.scanStoredChannels(async (entry) => {
719
- result.push(entry)
720
- })
721
-
722
- return result
723
- }
724
-
725
- private async listBotChannels(selfId: string): Promise<StoredChannelEntry[]> {
726
- const result: StoredChannelEntry[] = []
727
-
728
- await this.scanStoredChannels(async (entry) => {
729
- if (entry.selfId === selfId) {
730
- result.push(entry)
731
- }
732
- }, selfId)
733
-
734
- return result
735
- }
736
-
737
- private async scanStoredChannels(visitor: (entry: StoredChannelEntry) => Promise<void>, botIdFilter?: string) {
738
- try {
739
- const botDirs = await fs.readdir(this.chatHistoryDir, { withFileTypes: true })
740
- for (const botEntry of botDirs) {
741
- const botName = this.normalizeDirentName(botEntry.name)
742
- if (!botEntry.isDirectory()) continue
743
- if (botIdFilter && botName !== botIdFilter) continue
744
-
745
- const botDir = path.join(this.chatHistoryDir, botName)
746
- const channelEntries = await fs.readdir(botDir, { withFileTypes: true })
747
-
748
- for (const channelEntry of channelEntries) {
749
- const channelId = await this.resolveStoredChannelId(botName, channelEntry)
750
- if (!channelId) {
751
- continue
752
- }
753
-
754
- await visitor(this.createStoredChannelEntry(botName, channelId))
755
- }
756
- }
757
- } catch (error) {
758
- if (!this.isFileMissingError(error)) {
759
- this.logger.error('扫描频道文件失败:', error)
760
- }
761
- }
762
- }
763
-
764
- private createStoredChannelEntry(selfId: string, channelId: string): StoredChannelEntry {
765
- const channelDirPath = this.getChannelDirPath(selfId, channelId)
766
- return {
767
- selfId,
768
- channelId,
769
- channelKey: `${selfId}:${channelId}`,
770
- channelDirPath,
771
- indexFilePath: this.getChannelIndexPath(selfId, channelId)
772
- }
773
- }
774
-
775
- private async resolveStoredChannelId(selfId: string, entry: { isFile(): boolean, isDirectory(): boolean, name: string | Buffer }) {
776
- const entryName = this.normalizeDirentName(entry.name)
777
-
778
- if (!entry.isDirectory()) {
779
- return undefined
780
- }
781
-
782
- const encodedChannelId = entryName
783
- const channelDirPath = path.join(this.chatHistoryDir, selfId, encodedChannelId)
784
- const indexFilePath = path.join(channelDirPath, 'index.json')
785
-
786
- try {
787
- const jsonData = await fs.readFile(indexFilePath, 'utf8')
788
- const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
789
- if (typeof indexData.channelId === 'string') {
790
- return indexData.channelId
791
- }
792
- } catch (error) {
793
- if (!this.isFileMissingError(error)) {
794
- this.logger.error(`读取频道索引失败 [${selfId}:${entryName}]:`, error)
795
- }
796
- }
797
-
798
- return this.decodeChannelId(encodedChannelId)
799
- }
800
-
801
- private getBotDirPath(selfId: string) {
802
- return path.join(this.chatHistoryDir, selfId)
803
- }
804
-
805
- private getEncodedChannelId(channelId: string) {
806
- return encodeURIComponent(channelId)
807
- }
808
-
809
- private decodeChannelId(encodedChannelId: string) {
810
- try {
811
- return decodeURIComponent(encodedChannelId)
812
- } catch {
813
- return encodedChannelId
814
- }
815
- }
816
-
817
- private normalizeDirentName(name: string | Buffer) {
818
- return typeof name === 'string' ? name : name.toString('utf8')
819
- }
820
-
821
- private getChannelDirPath(selfId: string, channelId: string) {
822
- return path.join(this.getBotDirPath(selfId), this.getEncodedChannelId(channelId))
823
- }
824
-
825
- private getChannelIndexPath(selfId: string, channelId: string) {
826
- return path.join(this.getChannelDirPath(selfId, channelId), 'index.json')
827
- }
828
-
829
- private getChunkFilePath(channelDirPath: string, fileName: string) {
830
- return path.join(channelDirPath, fileName)
831
- }
832
-
833
- private createEmptyChannelIndex(channelId: string): ChannelIndexData {
834
- return {
835
- version: 1,
836
- channelId,
837
- totalMessages: 0,
838
- nextChunkId: 1,
839
- chunks: []
840
- }
841
- }
842
-
843
- private createChunkFileName(chunkId: number) {
844
- return `chunk-${String(chunkId).padStart(6, '0')}.json`
845
- }
846
-
847
- private async loadOrCreateChannelIndex(selfId: string, channelId: string): Promise<ChannelIndexData> {
848
- const entry = this.createStoredChannelEntry(selfId, channelId)
849
-
850
- try {
851
- const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
852
- const indexData = JSON.parse(jsonData) as ChannelIndexData
853
- const normalizedIndexData = this.normalizeChannelIndex(channelId, indexData)
854
- this.syncDirtyChannelState(entry.channelKey, normalizedIndexData.totalMessages)
855
- return normalizedIndexData
856
- } catch (error) {
857
- if (!this.isFileMissingError(error)) {
858
- this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
859
- }
860
- }
861
-
862
- const indexData = this.createEmptyChannelIndex(channelId)
863
- await this.ensureDir(entry.channelDirPath)
864
-
865
- await this.writeChannelIndex(entry.indexFilePath, indexData)
866
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
867
-
868
- return indexData
869
- }
870
-
871
- private normalizeChannelIndex(channelId: string, indexData: ChannelIndexData): ChannelIndexData {
872
- return {
873
- version: 1,
874
- channelId,
875
- totalMessages: indexData.totalMessages || 0,
876
- nextChunkId: indexData.nextChunkId || (indexData.chunks?.length || 0) + 1,
877
- chunks: Array.isArray(indexData.chunks) ? indexData.chunks : []
878
- }
879
- }
880
-
881
- private async writeChannelIndex(indexFilePath: string, indexData: ChannelIndexData) {
882
- await this.ensureDir(path.dirname(indexFilePath))
883
- await this.atomicWriteTextFile(indexFilePath, JSON.stringify(indexData, null, 2))
884
- }
885
-
886
- private async readChunkMessages(channelDirPath: string, fileName: string): Promise<MessageInfo[]> {
887
- try {
888
- const jsonData = await fs.readFile(this.getChunkFilePath(channelDirPath, fileName), 'utf8')
889
- const messages = JSON.parse(jsonData)
890
- return Array.isArray(messages) ? messages : []
891
- } catch (error) {
892
- if (!this.isFileMissingError(error)) {
893
- this.logger.error(`读取消息分块失败 [${channelDirPath}/${fileName}]:`, error)
894
- }
895
- return []
896
- }
897
- }
898
-
899
- private async writeChunkMessages(channelDirPath: string, fileName: string, messages: MessageInfo[]) {
900
- await this.ensureDir(channelDirPath)
901
- await this.atomicWriteTextFile(this.getChunkFilePath(channelDirPath, fileName), JSON.stringify(messages, null, 2))
902
- }
903
-
904
- private async writeMessagesToChunks(channelDirPath: string, indexData: ChannelIndexData, messages: MessageInfo[]) {
905
- const oldChunks = [...indexData.chunks]
906
- indexData.chunks = []
907
- indexData.totalMessages = 0
908
- indexData.nextChunkId = 1
909
-
910
- for (let offset = 0; offset < messages.length; offset += this.config.messageChunkSize) {
911
- const chunkMessages = messages.slice(offset, offset + this.config.messageChunkSize)
912
- const chunkId = indexData.nextChunkId
913
- const fileName = this.createChunkFileName(chunkId)
914
- await this.writeChunkMessages(channelDirPath, fileName, chunkMessages)
915
- indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
916
- indexData.nextChunkId += 1
917
- indexData.totalMessages += chunkMessages.length
918
- }
919
-
920
- for (const chunk of oldChunks) {
921
- try {
922
- await fs.unlink(this.getChunkFilePath(channelDirPath, chunk.fileName))
923
- } catch (error) {
924
- if (!this.isFileMissingError(error)) {
925
- this.logger.warn(`删除旧消息分块失败 [${channelDirPath}/${chunk.fileName}]:`, error)
926
- }
927
- }
928
- }
929
-
930
- await this.writeChannelIndex(path.join(channelDirPath, 'index.json'), indexData)
931
- }
932
-
933
- private async appendMessagesToChannel(selfId: string, channelId: string, messages: MessageInfo[]) {
934
- if (!messages.length) {
935
- return
936
- }
937
-
938
- const entry = this.createStoredChannelEntry(selfId, channelId)
939
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
940
- await this.ensureDir(entry.channelDirPath)
941
-
942
- let remainingMessages = [...messages]
943
- const lastChunk = indexData.chunks[indexData.chunks.length - 1]
944
-
945
- if (lastChunk && lastChunk.messageCount < this.config.messageChunkSize) {
946
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, lastChunk.fileName)
947
- const writableCount = this.config.messageChunkSize - chunkMessages.length
948
- const appendMessages = remainingMessages.slice(0, writableCount)
949
- if (appendMessages.length) {
950
- chunkMessages.push(...appendMessages)
951
- lastChunk.messageCount = chunkMessages.length
952
- indexData.totalMessages += appendMessages.length
953
- remainingMessages = remainingMessages.slice(appendMessages.length)
954
- await this.writeChunkMessages(entry.channelDirPath, lastChunk.fileName, chunkMessages)
955
- this.rememberMessageChunkLocation(entry.channelKey, lastChunk.fileName, appendMessages.map((message) => message.id))
956
- }
957
- }
958
-
959
- while (remainingMessages.length) {
960
- const chunkMessages = remainingMessages.slice(0, this.config.messageChunkSize)
961
- const chunkId = indexData.nextChunkId
962
- const fileName = this.createChunkFileName(chunkId)
963
- await this.writeChunkMessages(entry.channelDirPath, fileName, chunkMessages)
964
- indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
965
- indexData.nextChunkId += 1
966
- indexData.totalMessages += chunkMessages.length
967
- this.rememberMessageChunkLocation(entry.channelKey, fileName, chunkMessages.map((message) => message.id))
968
- remainingMessages = remainingMessages.slice(chunkMessages.length)
969
- }
970
-
971
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
972
- await this.writeChannelIndex(entry.indexFilePath, indexData)
973
- }
974
-
975
- private async trimChannelToLimit(selfId: string, channelId: string, indexData: ChannelIndexData, limit: number) {
976
- const entry = this.createStoredChannelEntry(selfId, channelId)
977
- let overflow = indexData.totalMessages - limit
978
- if (overflow <= 0) {
979
- return 0
980
- }
981
-
982
- let removedCount = 0
983
-
984
- while (overflow > 0 && indexData.chunks.length) {
985
- const chunk = indexData.chunks[0]
986
- if (chunk.messageCount <= overflow) {
987
- const removedChunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
988
- overflow -= chunk.messageCount
989
- removedCount += chunk.messageCount
990
- indexData.totalMessages -= chunk.messageCount
991
- indexData.chunks.shift()
992
- this.forgetRecentMessageIds(entry.channelKey, removedChunkMessages.map((message) => message.id))
993
- this.forgetMessageChunkLocation(entry.channelKey, removedChunkMessages.map((message) => message.id))
994
- try {
995
- await fs.unlink(this.getChunkFilePath(entry.channelDirPath, chunk.fileName))
996
- } catch (error) {
997
- if (!this.isFileMissingError(error)) {
998
- this.logger.warn(`删除消息分块失败 [${entry.channelKey}:${chunk.fileName}]:`, error)
999
- }
1000
- }
1001
- continue
1002
- }
1003
-
1004
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1005
- const keptMessages = chunkMessages.slice(overflow)
1006
- const removedMessages = chunkMessages.slice(0, overflow)
1007
- removedCount += overflow
1008
- indexData.totalMessages -= overflow
1009
- chunk.messageCount = keptMessages.length
1010
- overflow = 0
1011
- this.forgetRecentMessageIds(entry.channelKey, removedMessages.map((message) => message.id))
1012
- this.forgetMessageChunkLocation(entry.channelKey, removedMessages.map((message) => message.id))
1013
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, keptMessages)
1014
- }
1015
-
1016
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
1017
- await this.writeChannelIndex(entry.indexFilePath, indexData)
1018
- return removedCount
1019
- }
1020
-
1021
- private async countChannelMessages(selfId: string, channelId: string) {
1022
- const entry = this.createStoredChannelEntry(selfId, channelId)
1023
-
1024
- try {
1025
- const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
1026
- const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
1027
- if (typeof indexData.totalMessages === 'number') {
1028
- return indexData.totalMessages
1029
- }
1030
- } catch (error) {
1031
- if (!this.isFileMissingError(error)) {
1032
- this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
1033
- }
1034
- }
1035
-
1036
- return 0
1037
- }
1038
-
1039
- private async removeChannelStorage(selfId: string, channelId: string) {
1040
- const entry = this.createStoredChannelEntry(selfId, channelId)
1041
-
1042
- try {
1043
- await fs.rm(entry.channelDirPath, { recursive: true, force: true })
1044
- } catch (error) {
1045
- this.logger.error(`删除频道目录失败 [${entry.channelKey}]:`, error)
1046
- }
1047
- }
1048
-
1049
- private async updateUserProfileInChannel(selfId: string, channelId: string, userId: string, userName?: string, avatar?: string) {
1050
- const entry = this.createStoredChannelEntry(selfId, channelId)
1051
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1052
- let changed = false
1053
-
1054
- for (const chunk of indexData.chunks) {
1055
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1056
- let chunkChanged = false
1057
-
1058
- for (const message of messages) {
1059
- if (message.userId !== userId) {
1060
- continue
1061
- }
1062
-
1063
- if (userName && message.username !== userName) {
1064
- message.username = userName
1065
- chunkChanged = true
1066
- }
1067
-
1068
- if (avatar && message.avatar !== avatar) {
1069
- message.avatar = avatar
1070
- chunkChanged = true
1071
- }
1072
- }
1073
-
1074
- if (!chunkChanged) {
1075
- continue
1076
- }
1077
-
1078
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
1079
- changed = true
1080
- }
1081
-
1082
- return changed
1083
- }
1084
-
1085
- private async findAndUpdateLatestBotMessage(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
1086
- const entry = this.createStoredChannelEntry(selfId, channelId)
1087
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1088
-
1089
- for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1090
- const chunk = indexData.chunks[index]
1091
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1092
- const matched = [...messages].reverse().find((message) => message.type === 'bot' && message.sending)
1093
- if (!matched) {
1094
- continue
1095
- }
1096
-
1097
- matched.realId = realId
1098
- matched.sending = false
1099
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
1100
- return matched
1101
- }
1102
-
1103
- return undefined
1104
- }
1105
-
1106
- private async findAndUpdateBotMessageByTempId(selfId: string, channelId: string, tempMessageId: string, realId: string): Promise<MessageInfo | undefined> {
1107
- const channelKey = `${selfId}:${channelId}`
1108
- const pendingMessages = this.pendingMessages.get(channelKey)
1109
- const pendingMatched = pendingMessages?.find((message) => message.id === tempMessageId)
1110
- if (pendingMatched) {
1111
- pendingMatched.realId = realId
1112
- pendingMatched.sending = false
1113
- return pendingMatched
1114
- }
1115
-
1116
- const cachedMessages = this.peekCachedChannelMessages(channelKey)
1117
- const cachedMatched = cachedMessages?.find((message) => message.id === tempMessageId)
1118
- if (cachedMatched) {
1119
- cachedMatched.realId = realId
1120
- cachedMatched.sending = false
1121
- this.setCachedChannelMessages(channelKey, cachedMessages)
1122
- }
1123
-
1124
- const entry = this.createStoredChannelEntry(selfId, channelId)
1125
- const chunkFileName = this.getMessageChunkLocation(channelKey, tempMessageId)
1126
- if (chunkFileName) {
1127
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunkFileName)
1128
- const matched = chunkMessages.find((message) => message.id === tempMessageId)
1129
- if (matched) {
1130
- matched.realId = realId
1131
- matched.sending = false
1132
- await this.writeChunkMessages(entry.channelDirPath, chunkFileName, chunkMessages)
1133
- return matched
1134
- }
1135
- }
1136
-
1137
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1138
- for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1139
- const chunk = indexData.chunks[index]
1140
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1141
- const matched = chunkMessages.find((message) => message.id === tempMessageId)
1142
- if (!matched) {
1143
- continue
1144
- }
1145
-
1146
- matched.realId = realId
1147
- matched.sending = false
1148
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, chunkMessages)
1149
- this.rememberMessageChunkLocation(channelKey, chunk.fileName, [matched.id])
1150
- return matched
1151
- }
1152
-
1153
- return cachedMatched
1154
- }
1155
-
1156
- private deduplicateMessages(messages: MessageInfo[]) {
1157
- const seen = new Set<string>()
1158
- const deduplicated: MessageInfo[] = []
1159
-
1160
- for (const message of messages) {
1161
- if (seen.has(message.id)) {
1162
- continue
1163
- }
1164
- seen.add(message.id)
1165
- deduplicated.push(message)
1166
- }
1167
-
1168
- return deduplicated
1169
- }
1170
-
1171
- private getCachedChannelMessages(channelKey: string) {
1172
- const cached = this.channelMessagesCache.get(channelKey)
1173
- if (!cached) {
1174
- return undefined
1175
- }
1176
-
1177
- this.channelMessagesCache.delete(channelKey)
1178
- this.channelMessagesCache.set(channelKey, cached)
1179
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
1180
- return cached
1181
- }
1182
-
1183
- private peekCachedChannelMessages(channelKey: string) {
1184
- return this.channelMessagesCache.get(channelKey)
1185
- }
1186
-
1187
- private setCachedChannelMessages(channelKey: string, messages: MessageInfo[]) {
1188
- this.channelMessagesCache.delete(channelKey)
1189
- this.channelMessagesCache.set(channelKey, messages)
1190
- this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
1191
-
1192
- while (this.channelMessagesCache.size > this.config.channelCacheLimit) {
1193
- const oldestKey = this.channelMessagesCache.keys().next().value as string | undefined
1194
- if (!oldestKey) {
1195
- break
1196
- }
1197
- this.channelMessagesCache.delete(oldestKey)
1198
- this.deleteRecentMessageIds(oldestKey)
1199
- this.deleteMessageChunkLocations(oldestKey)
1200
- }
1201
-
1202
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
1203
- }
1204
-
1205
- private deleteCachedChannelMessages(channelKey: string) {
1206
- this.channelMessagesCache.delete(channelKey)
1207
- delete this.memoryCache.messages[channelKey]
1208
- }
1209
-
1210
- private getChannelMessagesCacheSnapshot() {
1211
- return Object.fromEntries(this.channelMessagesCache.entries())
1212
- }
1213
-
1214
- private mergeChannelMessages(baseMessages: MessageInfo[], appendedMessages: MessageInfo[]) {
1215
- const merged = [...baseMessages]
1216
- const knownIds = new Set(baseMessages.map((message) => message.id))
1217
-
1218
- for (const message of appendedMessages) {
1219
- if (knownIds.has(message.id)) {
1220
- continue
1221
- }
1222
- knownIds.add(message.id)
1223
- merged.push(message)
1224
- }
1225
-
1226
- return merged
1227
- }
1228
-
1229
- private limitChannelMessages(messages: MessageInfo[]) {
1230
- if (messages.length <= this.config.maxMessagesPerChannel) {
1231
- return messages
1232
- }
1233
-
1234
- return [...messages]
1235
- .sort((left, right) => left.timestamp - right.timestamp)
1236
- .slice(-this.config.maxMessagesPerChannel)
1237
- }
1238
-
1239
- private async channelMessageExists(selfId: string, channelId: string, messageId: string) {
1240
- const entry = this.createStoredChannelEntry(selfId, channelId)
1241
- const channelKey = entry.channelKey
1242
-
1243
- if (this.hasRecentMessageId(channelKey, messageId)) {
1244
- return true
1245
- }
1246
-
1247
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
1248
-
1249
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
1250
- const chunk = indexData.chunks[chunkIndex]
1251
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
1252
- if (messages.some((message) => message.id === messageId)) {
1253
- this.rememberRecentMessageIds(channelKey, [messageId])
1254
- return true
1255
- }
1256
- }
1257
-
1258
- return false
1259
- }
1260
-
1261
- private syncDirtyChannelState(channelKey: string, totalMessages: number) {
1262
- if (totalMessages > this.config.maxMessagesPerChannel) {
1263
- this.dirtyChannelKeys.add(channelKey)
1264
- return
1265
- }
1266
-
1267
- this.dirtyChannelKeys.delete(channelKey)
1268
- }
1269
-
1270
- private registerPendingBotMessage(channelKey: string, messageId: string) {
1271
- const messageIds = this.pendingBotMessageIds.get(channelKey) || []
1272
- messageIds.push(messageId)
1273
- this.pendingBotMessageIds.set(channelKey, messageIds)
1274
- }
1275
-
1276
- private peekLatestPendingBotMessageId(channelKey: string) {
1277
- const messageIds = this.pendingBotMessageIds.get(channelKey)
1278
- return messageIds?.[messageIds.length - 1]
1279
- }
1280
-
1281
- private consumePendingBotMessageId(channelKey: string, messageId: string) {
1282
- const messageIds = this.pendingBotMessageIds.get(channelKey)
1283
- if (!messageIds?.length) {
1284
- return
1285
- }
1286
-
1287
- const nextMessageIds = messageIds.filter((id) => id !== messageId)
1288
- if (nextMessageIds.length) {
1289
- this.pendingBotMessageIds.set(channelKey, nextMessageIds)
1290
- return
1291
- }
1292
-
1293
- this.pendingBotMessageIds.delete(channelKey)
1294
- }
1295
-
1296
- private deletePendingBotMessages(channelKey: string) {
1297
- this.pendingBotMessageIds.delete(channelKey)
1298
- }
1299
-
1300
- private getRecentMessageIdCacheLimit() {
1301
- return Math.max(this.RECENT_MESSAGE_ID_CACHE_SIZE, this.config.messageChunkSize * 2)
1302
- }
1303
-
1304
- private rememberRecentMessageIds(channelKey: string, messageIds: string[]) {
1305
- if (!messageIds.length) {
1306
- return
1307
- }
1308
-
1309
- const nextMessageIds = [...(this.recentMessageIdsCache.get(channelKey) || [])]
1310
- for (const messageId of messageIds) {
1311
- const existingIndex = nextMessageIds.indexOf(messageId)
1312
- if (existingIndex !== -1) {
1313
- nextMessageIds.splice(existingIndex, 1)
1314
- }
1315
- nextMessageIds.push(messageId)
1316
- }
1317
-
1318
- const maxSize = this.getRecentMessageIdCacheLimit()
1319
- this.recentMessageIdsCache.set(channelKey, nextMessageIds.slice(-maxSize))
1320
- }
1321
-
1322
- private forgetRecentMessageIds(channelKey: string, messageIds: string[]) {
1323
- const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
1324
- if (!currentMessageIds?.length || !messageIds.length) {
1325
- return
1326
- }
1327
-
1328
- const nextMessageIds = currentMessageIds.filter((messageId) => !messageIds.includes(messageId))
1329
- if (nextMessageIds.length) {
1330
- this.recentMessageIdsCache.set(channelKey, nextMessageIds)
1331
- return
1332
- }
1333
-
1334
- this.recentMessageIdsCache.delete(channelKey)
1335
- }
1336
-
1337
- private hasRecentMessageId(channelKey: string, messageId: string) {
1338
- const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
1339
- return !!currentMessageIds?.includes(messageId)
1340
- }
1341
-
1342
- private deleteRecentMessageIds(channelKey: string) {
1343
- this.recentMessageIdsCache.delete(channelKey)
1344
- }
1345
-
1346
- private rememberMessageChunkLocation(channelKey: string, chunkFileName: string, messageIds: string[]) {
1347
- if (!messageIds.length) {
1348
- return
1349
- }
1350
-
1351
- const currentLocations = this.messageChunkLocationCache.get(channelKey) || new Map<string, string>()
1352
- for (const messageId of messageIds) {
1353
- currentLocations.set(messageId, chunkFileName)
1354
- }
1355
- this.messageChunkLocationCache.set(channelKey, currentLocations)
1356
- }
1357
-
1358
- private forgetMessageChunkLocation(channelKey: string, messageIds: string[]) {
1359
- const currentLocations = this.messageChunkLocationCache.get(channelKey)
1360
- if (!currentLocations || !messageIds.length) {
1361
- return
1362
- }
1363
-
1364
- for (const messageId of messageIds) {
1365
- currentLocations.delete(messageId)
1366
- }
1367
-
1368
- if (!currentLocations.size) {
1369
- this.messageChunkLocationCache.delete(channelKey)
1370
- }
1371
- }
1372
-
1373
- private getMessageChunkLocation(channelKey: string, messageId: string) {
1374
- return this.messageChunkLocationCache.get(channelKey)?.get(messageId)
1375
- }
1376
-
1377
- private deleteMessageChunkLocations(channelKey: string) {
1378
- this.messageChunkLocationCache.delete(channelKey)
1379
- }
1380
-
1381
- private isSameBotInfo(left: BotInfo, right: BotInfo) {
1382
- return left.selfId === right.selfId
1383
- && left.platform === right.platform
1384
- && left.username === right.username
1385
- && left.avatar === right.avatar
1386
- && left.status === right.status
1387
- }
1388
-
1389
- private isSameChannelInfo(left: ChannelInfo, right: ChannelInfo) {
1390
- return left.id === right.id
1391
- && left.name === right.name
1392
- && left.type === right.type
1393
- && left.channelId === right.channelId
1394
- && left.guildName === right.guildName
1395
- && left.isDirect === right.isDirect
1396
- }
1397
-
1398
- private isFileMissingError(error: unknown): boolean {
1399
- return typeof error === 'object'
1400
- && error !== null
1401
- && 'code' in error
1402
- && error.code === 'ENOENT'
1403
- }
1404
-
1405
- private async atomicWriteTextFile(filePath: string, content: string) {
1406
- const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
1407
-
1408
- await fs.writeFile(tempFilePath, content, 'utf8'
1409
- )
1410
-
1411
- try {
1412
- await fs.rename(tempFilePath, filePath)
1413
- } catch (error) {
1414
- if (this.isAtomicRenameReplaceError(error)) {
1415
- await fs.rm(filePath, { force: true })
1416
- await fs.rename(tempFilePath, filePath)
1417
- return
1418
- }
1419
-
1420
- try {
1421
- await fs.rm(tempFilePath, { force: true })
1422
- } catch {
1423
- // 忽略临时文件清理失败,保留原始错误即可。
1424
- }
1425
-
1426
- throw error
1427
- }
1428
- }
1429
-
1430
- private isAtomicRenameReplaceError(error: unknown): boolean {
1431
- return typeof error === 'object'
1432
- && error !== null
1433
- && 'code' in error
1434
- && (error.code === 'EEXIST' || error.code === 'EPERM')
1435
- }
1436
- }