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
@@ -0,0 +1,650 @@
1
+ import { Level } from 'level'
2
+ import { Context } from 'koishi'
3
+ import path from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ import { Config } from './config'
7
+ import { ContactCacheItem, MessageRecord, PinnedState, SelfMessageRecord } from './types'
8
+ import { PluginLogger } from './logger'
9
+
10
+ function encodeKeyPart(value: string): string {
11
+ return encodeURIComponent(value)
12
+ }
13
+
14
+ function decodeKeyPart(value: string): string {
15
+ try {
16
+ return decodeURIComponent(value)
17
+ } catch {
18
+ return value
19
+ }
20
+ }
21
+
22
+ function messagePrefix(platform: string, selfId: string, channelId: string): string {
23
+ return `m:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
24
+ }
25
+
26
+ function legacyMessagePrefix(platform: string, selfId: string, channelId: string): string {
27
+ return `m:${platform}:${selfId}:${channelId}:`
28
+ }
29
+
30
+ function messageKey(record: MessageRecord): string {
31
+ const time = String(record.timestamp).padStart(16, '0')
32
+ return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
33
+ }
34
+
35
+ // 独立命名空间保存机器人自身消息,避免和收到的用户消息混用
36
+ function selfMessagePrefix(platform: string, selfId: string, channelId: string): string {
37
+ return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
38
+ }
39
+
40
+ function selfMessageKey(record: SelfMessageRecord): string {
41
+ const time = String(record.sentAt).padStart(16, '0')
42
+ return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
43
+ }
44
+
45
+ function contactKey(platform: string, selfId: string, type: string): string {
46
+ return `c:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(type)}`
47
+ }
48
+
49
+ function legacyContactKey(platform: string, selfId: string, type: string): string {
50
+ return `c:${platform}:${selfId}:${type}`
51
+ }
52
+
53
+ function groupMemberKey(platform: string, selfId: string, groupId: string): string {
54
+ return `gm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(groupId)}`
55
+ }
56
+
57
+ function legacyGroupMemberKey(platform: string, selfId: string, groupId: string): string {
58
+ return `gm:${platform}:${selfId}:${groupId}`
59
+ }
60
+
61
+ function isPrivateChannelId(value: unknown): boolean {
62
+ return typeof value === 'string' && /^(?:private|direct):/i.test(value)
63
+ }
64
+
65
+ function isPrivateChannelType(value: unknown): boolean {
66
+ const num = Number(value)
67
+ if (Number.isFinite(num)) return num === 1
68
+ const text = String(value ?? '').toLowerCase()
69
+ return text === 'direct' || text === 'private'
70
+ }
71
+
72
+ function isUsableGroupContact(item: ContactCacheItem): boolean {
73
+ if (isPrivateChannelId(item.channelId)) return false
74
+ if (typeof item.raw === 'object' && item.raw !== null) {
75
+ const raw = item.raw as Record<string, unknown>
76
+ if (isPrivateChannelType(raw.channel_type)
77
+ || isPrivateChannelId(raw.channel_id)
78
+ || isPrivateChannelId(raw.channelId)) return false
79
+ }
80
+ return true
81
+ }
82
+
83
+ export class ChatDatabase {
84
+ private db: Level<string, string>
85
+ private opened = false
86
+
87
+ constructor(
88
+ private ctx: Context,
89
+ private config: Config,
90
+ private logger: PluginLogger,
91
+ ) {
92
+ const dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
93
+ this.db = new Level<string, string>(dir, { keyEncoding: 'utf8', valueEncoding: 'utf8' })
94
+ }
95
+
96
+ async initialize() {
97
+ await this.db.open()
98
+ this.opened = true
99
+ this.logger.logInfo('LevelDB 已打开:', this.db.location)
100
+ }
101
+
102
+ async dispose() {
103
+ if (!this.opened) return
104
+ await this.db.close()
105
+ this.opened = false
106
+ this.logger.logInfo('LevelDB closed')
107
+ }
108
+
109
+ async appendMessage(record: MessageRecord) {
110
+ await this.db.put(messageKey(record), JSON.stringify(record))
111
+ await this.trimMessages(record.platform, record.selfId, record.channelId || '')
112
+ }
113
+
114
+ async upsertSelfMessage(record: SelfMessageRecord) {
115
+ if (record.messageId) {
116
+ await this.removeSelfMessageByMessageId(
117
+ record.platform,
118
+ record.selfId,
119
+ record.channelId,
120
+ record.messageId,
121
+ record.id,
122
+ )
123
+ }
124
+ await this.db.put(selfMessageKey(record), JSON.stringify(record))
125
+ await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
126
+ }
127
+
128
+ async listSelfMessages(
129
+ platform: string,
130
+ selfId: string,
131
+ channelId: string,
132
+ limit = this.config.historyPageSize,
133
+ ): Promise<SelfMessageRecord[]> {
134
+ const result: SelfMessageRecord[] = []
135
+ const prefix = selfMessagePrefix(platform, selfId, channelId)
136
+ for await (const [, value] of this.db.iterator<string, string>({
137
+ gte: prefix,
138
+ lte: `${prefix}\uffff`,
139
+ reverse: true,
140
+ limit,
141
+ })) {
142
+ const parsed = this.parseSelfMessage(value)
143
+ if (parsed) result.push(parsed)
144
+ }
145
+ return result
146
+ }
147
+
148
+ async listSelfMessagesBefore(
149
+ platform: string,
150
+ selfId: string,
151
+ channelId: string,
152
+ beforeTime: number,
153
+ limit = this.config.historyPageSize,
154
+ ): Promise<SelfMessageRecord[]> {
155
+ const result: SelfMessageRecord[] = []
156
+ const prefix = selfMessagePrefix(platform, selfId, channelId)
157
+ const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
158
+ for await (const [, value] of this.db.iterator<string, string>({
159
+ gte: prefix,
160
+ lt: before,
161
+ reverse: true,
162
+ limit,
163
+ })) {
164
+ const parsed = this.parseSelfMessage(value)
165
+ if (parsed) result.push(parsed)
166
+ }
167
+ return result
168
+ }
169
+
170
+ async updateSelfMessageByMessageId(
171
+ platform: string,
172
+ selfId: string,
173
+ _channelId: string,
174
+ messageId: string,
175
+ patch: Partial<SelfMessageRecord>,
176
+ ): Promise<boolean> {
177
+ const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
178
+ for await (const [key, value] of this.db.iterator<string, string>({
179
+ gte: prefix,
180
+ lte: `${prefix}\uffff`,
181
+ })) {
182
+ const parsed = this.parseSelfMessage(value)
183
+ if (!parsed || parsed.messageId !== messageId) continue
184
+ const next: SelfMessageRecord = {
185
+ ...parsed,
186
+ ...patch,
187
+ id: parsed.id,
188
+ sentAt: parsed.sentAt,
189
+ }
190
+ await this.db.put(key, JSON.stringify(next))
191
+ return true
192
+ }
193
+ return false
194
+ }
195
+
196
+ async findSelfForwardContent(
197
+ platform: string,
198
+ selfId: string,
199
+ channelId: string,
200
+ id: string,
201
+ ): Promise<unknown[] | null> {
202
+ const prefixes = channelId
203
+ ? [selfMessagePrefix(platform, selfId, channelId)]
204
+ : [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
205
+ for (const prefix of prefixes) {
206
+ for await (const [, value] of this.db.iterator<string, string>({
207
+ gte: prefix,
208
+ lte: `${prefix}\uffff`,
209
+ })) {
210
+ const parsed = this.parseSelfMessage(value)
211
+ if (!parsed) continue
212
+ if (parsed.forwardId === id || parsed.messageId === id) {
213
+ if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
214
+ return parsed.forwardContent
215
+ }
216
+ }
217
+ }
218
+ }
219
+ return null
220
+ }
221
+
222
+ private async removeSelfMessageByMessageId(
223
+ platform: string,
224
+ selfId: string,
225
+ _channelId: string,
226
+ messageId: string,
227
+ exceptId: string,
228
+ ) {
229
+ const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
230
+ const toDelete: string[] = []
231
+ for await (const [key, value] of this.db.iterator<string, string>({
232
+ gte: prefix,
233
+ lte: `${prefix}\uffff`,
234
+ })) {
235
+ const parsed = this.parseSelfMessage(value)
236
+ if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
237
+ toDelete.push(key)
238
+ }
239
+ }
240
+ if (toDelete.length) {
241
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
242
+ }
243
+ }
244
+
245
+ private parseSelfMessage(value: string): SelfMessageRecord | null {
246
+ try {
247
+ const parsed = JSON.parse(value) as unknown
248
+ if (typeof parsed === 'object' && parsed !== null) {
249
+ const record = parsed as Partial<SelfMessageRecord>
250
+ if (typeof record.id === 'string'
251
+ && typeof record.platform === 'string'
252
+ && typeof record.selfId === 'string'
253
+ && typeof record.channelId === 'string'
254
+ && typeof record.sentAt === 'number') {
255
+ return record as SelfMessageRecord
256
+ }
257
+ }
258
+ } catch {
259
+ this.logger.warn('机器人消息解析失败:', value.slice(0, 120))
260
+ }
261
+ return null
262
+ }
263
+
264
+ async listMessages(
265
+ platform: string,
266
+ selfId: string,
267
+ channelId: string,
268
+ limit = this.config.historyPageSize,
269
+ ): Promise<MessageRecord[]> {
270
+ const result: MessageRecord[] = []
271
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
272
+ for await (const [, value] of this.db.iterator<string, string>({
273
+ gte: prefix,
274
+ lte: `${prefix}\uffff`,
275
+ reverse: true,
276
+ limit: limit - result.length,
277
+ })) {
278
+ try {
279
+ const parsed = JSON.parse(value) as unknown
280
+ if (typeof parsed === 'object' && parsed !== null) {
281
+ result.push(parsed as MessageRecord)
282
+ }
283
+ } catch {
284
+ this.logger.warn('历史消息解析失败:', value.slice(0, 120))
285
+ }
286
+ }
287
+ if (result.length >= limit) break
288
+ }
289
+ return result
290
+ }
291
+
292
+ async listMessagesBefore(
293
+ platform: string,
294
+ selfId: string,
295
+ channelId: string,
296
+ beforeTime: number,
297
+ limit = this.config.historyPageSize,
298
+ ): Promise<MessageRecord[]> {
299
+ const result: MessageRecord[] = []
300
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
301
+ const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
302
+ for await (const [, value] of this.db.iterator<string, string>({
303
+ gte: prefix,
304
+ lt: before,
305
+ reverse: true,
306
+ limit: limit - result.length,
307
+ })) {
308
+ try {
309
+ const parsed = JSON.parse(value) as unknown
310
+ if (typeof parsed === 'object' && parsed !== null) {
311
+ result.push(parsed as MessageRecord)
312
+ }
313
+ } catch {
314
+ this.logger.warn('历史消息解析失败:', value.slice(0, 120))
315
+ }
316
+ }
317
+ if (result.length >= limit) break
318
+ }
319
+ return result
320
+ }
321
+
322
+ async clearChannel(platform: string, selfId: string, channelId: string) {
323
+ const operations: Array<{ type: 'del'; key: string }> = []
324
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
325
+ for await (const [key] of this.db.iterator<string, string>({
326
+ gte: prefix,
327
+ lte: `${prefix}\uffff`,
328
+ })) {
329
+ operations.push({ type: 'del', key })
330
+ }
331
+ }
332
+ const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
333
+ for await (const [key] of this.db.iterator<string, string>({
334
+ gte: selfPrefix,
335
+ lte: `${selfPrefix}\uffff`,
336
+ })) {
337
+ operations.push({ type: 'del', key })
338
+ }
339
+ if (operations.length) await this.db.batch(operations)
340
+ }
341
+
342
+ async getPinned(): Promise<PinnedState> {
343
+ try {
344
+ const value = await this.db.get('meta:pinned')
345
+ const parsed = JSON.parse(value) as unknown
346
+ if (typeof parsed === 'object' && parsed !== null) {
347
+ return parsed as PinnedState
348
+ }
349
+ } catch {
350
+ // 首次使用没有置顶数据
351
+ }
352
+ return { bots: [], channels: [] }
353
+ }
354
+
355
+ async setPinned(state: PinnedState) {
356
+ await this.db.put('meta:pinned', JSON.stringify(state))
357
+ }
358
+
359
+ async getMeta(key: string): Promise<unknown> {
360
+ try {
361
+ return JSON.parse(await this.db.get(`meta:${key}`)) as unknown
362
+ } catch {
363
+ return undefined
364
+ }
365
+ }
366
+
367
+ async setMeta(key: string, value: unknown) {
368
+ await this.db.put(`meta:${key}`, JSON.stringify(value))
369
+ }
370
+
371
+ async recordMedia(url: string, filePath: string, channelId = '') {
372
+ const key = `media:${createHash('md5').update(url).digest('hex')}`
373
+ await this.db.put(key, JSON.stringify({ filePath, channelId }))
374
+ }
375
+
376
+ async getMediaPath(url: string): Promise<string | undefined> {
377
+ try {
378
+ const key = `media:${createHash('md5').update(url).digest('hex')}`
379
+ const value = await this.db.get(key)
380
+ try {
381
+ const parsed = JSON.parse(value) as unknown
382
+ if (typeof parsed === 'object' && parsed !== null) {
383
+ const item = parsed as { filePath?: unknown }
384
+ return typeof item.filePath === 'string' ? item.filePath : value
385
+ }
386
+ } catch {
387
+ // 旧记录直接存的是文件路径
388
+ }
389
+ return value
390
+ } catch {
391
+ return undefined
392
+ }
393
+ }
394
+
395
+ async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
396
+ const result: Array<{ filePath: string; channelId: string }> = []
397
+ for await (const [, value] of this.db.iterator<string, string>({
398
+ gte: 'media:',
399
+ lte: 'media:\uffff',
400
+ })) {
401
+ try {
402
+ const parsed = JSON.parse(value) as unknown
403
+ if (typeof parsed === 'object' && parsed !== null) {
404
+ const item = parsed as { filePath?: unknown; channelId?: unknown }
405
+ if (typeof item.filePath === 'string') {
406
+ result.push({
407
+ filePath: item.filePath,
408
+ channelId: typeof item.channelId === 'string' ? item.channelId : '',
409
+ })
410
+ }
411
+ } else if (typeof parsed === 'string') {
412
+ result.push({ filePath: parsed, channelId: '' })
413
+ }
414
+ } catch {
415
+ result.push({ filePath: value, channelId: '' })
416
+ }
417
+ }
418
+ return result
419
+ }
420
+
421
+ async removeMediaByPath(filePath: string) {
422
+ const normalized = path.normalize(filePath)
423
+ const toDelete: string[] = []
424
+ for await (const [key, value] of this.db.iterator<string, string>({
425
+ gte: 'media:',
426
+ lte: 'media:\uffff',
427
+ })) {
428
+ let stored = value
429
+ try {
430
+ const parsed = JSON.parse(value) as unknown
431
+ if (typeof parsed === 'object' && parsed !== null) {
432
+ const item = parsed as { filePath?: unknown }
433
+ stored = typeof item.filePath === 'string' ? item.filePath : value
434
+ }
435
+ } catch {
436
+ // 旧格式直接存路径
437
+ }
438
+ if (path.normalize(stored) === normalized) toDelete.push(key)
439
+ }
440
+ if (toDelete.length) {
441
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
442
+ }
443
+ }
444
+
445
+ async getContacts(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
446
+ for (const key of [contactKey(platform, selfId, type), legacyContactKey(platform, selfId, type)]) {
447
+ try {
448
+ const value = await this.db.get(key)
449
+ const parsed = JSON.parse(value) as unknown
450
+ if (Array.isArray(parsed)) {
451
+ const contacts = parsed as ContactCacheItem[]
452
+ return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
453
+ }
454
+ } catch {
455
+ // 旧 key 或首次使用可能不存在
456
+ }
457
+ }
458
+ return []
459
+ }
460
+
461
+ async getContact(
462
+ platform: string,
463
+ selfId: string,
464
+ type: string,
465
+ id: string,
466
+ ): Promise<ContactCacheItem | null> {
467
+ const contacts = await this.getContacts(platform, selfId, type)
468
+ return contacts.find((item) => item.id === id) ?? null
469
+ }
470
+
471
+ async setContacts(platform: string, selfId: string, type: string, contacts: ContactCacheItem[]) {
472
+ const next = type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
473
+ await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next))
474
+ }
475
+
476
+ async appendContact(platform: string, selfId: string, type: string, contact: ContactCacheItem) {
477
+ if (type === 'group' && !isUsableGroupContact(contact)) return
478
+ const contacts = await this.getContacts(platform, selfId, type)
479
+ const next = contacts.filter((item) => item.id !== contact.id)
480
+ next.push(contact)
481
+ await this.setContacts(platform, selfId, type, next)
482
+ }
483
+
484
+ async getContactsLegacy(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
485
+ try {
486
+ const value = await this.db.get(legacyContactKey(platform, selfId, type))
487
+ const parsed = JSON.parse(value) as unknown
488
+ if (!Array.isArray(parsed)) return []
489
+ const contacts = parsed as ContactCacheItem[]
490
+ return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
491
+ } catch {
492
+ return []
493
+ }
494
+ }
495
+
496
+ async getGroupMembers(
497
+ platform: string,
498
+ selfId: string,
499
+ groupId: string,
500
+ ): Promise<ContactCacheItem[]> {
501
+ for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
502
+ try {
503
+ const value = await this.db.get(key)
504
+ const parsed = JSON.parse(value) as unknown
505
+ return Array.isArray(parsed) ? parsed as ContactCacheItem[] : []
506
+ } catch {
507
+ // 新键或旧键可能不存在,继续尝试另一个
508
+ }
509
+ }
510
+ return []
511
+ }
512
+
513
+ async setGroupMembers(
514
+ platform: string,
515
+ selfId: string,
516
+ groupId: string,
517
+ members: ContactCacheItem[],
518
+ ) {
519
+ await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members))
520
+ }
521
+
522
+ async getGroupMember(
523
+ platform: string,
524
+ selfId: string,
525
+ groupId: string,
526
+ userId: string,
527
+ ): Promise<ContactCacheItem | null> {
528
+ const members = await this.getGroupMembers(platform, selfId, groupId)
529
+ return members.find((item) => item.id === userId) ?? null
530
+ }
531
+
532
+ async appendGroupMember(
533
+ platform: string,
534
+ selfId: string,
535
+ groupId: string,
536
+ member: ContactCacheItem,
537
+ ) {
538
+ const members = await this.getGroupMembers(platform, selfId, groupId)
539
+ const next = members.filter((item) => item.id !== member.id)
540
+ next.push(member)
541
+ await this.setGroupMembers(platform, selfId, groupId, next)
542
+ }
543
+
544
+ async getAllContacts(): Promise<Array<{
545
+ platform: string
546
+ selfId: string
547
+ type: string
548
+ contacts: ContactCacheItem[]
549
+ }>> {
550
+ type ContactEntry = {
551
+ platform: string
552
+ selfId: string
553
+ type: string
554
+ contacts: ContactCacheItem[]
555
+ }
556
+ const result: Array<{
557
+ platform: string
558
+ selfId: string
559
+ type: string
560
+ contacts: ContactCacheItem[]
561
+ }> = []
562
+ const byTriple = new Map<string, {
563
+ kind: 'new' | 'legacy'
564
+ entry: ContactEntry
565
+ }>()
566
+ for await (const [key, value] of this.db.iterator<string, string>({
567
+ gte: 'c:',
568
+ lte: 'c:\uffff',
569
+ })) {
570
+ if (!key.startsWith('c:')) continue
571
+ let source: 'new' | 'legacy' = 'legacy'
572
+ let platform = ''
573
+ let selfId = ''
574
+ let type = ''
575
+ const parts = key.slice(2).split(':')
576
+ if (parts.length === 3) {
577
+ platform = decodeKeyPart(parts[0])
578
+ selfId = decodeKeyPart(parts[1])
579
+ type = decodeKeyPart(parts[2])
580
+ source = 'new'
581
+ } else {
582
+ const typeIndex = key.lastIndexOf(':')
583
+ if (typeIndex <= 2) continue
584
+ type = key.slice(typeIndex + 1)
585
+ const rest = key.slice(2, typeIndex)
586
+ const sep = rest.lastIndexOf(':')
587
+ if (sep <= 0) continue
588
+ platform = rest.slice(0, sep)
589
+ selfId = rest.slice(sep + 1)
590
+ }
591
+ if (!platform || !selfId || !type) continue
592
+ try {
593
+ const parsed = JSON.parse(value) as unknown
594
+ const entry: ContactEntry = {
595
+ platform,
596
+ selfId,
597
+ type,
598
+ contacts: Array.isArray(parsed) ? parsed as ContactCacheItem[] : [],
599
+ }
600
+ if (entry.type === 'group') {
601
+ entry.contacts = entry.contacts.filter(isUsableGroupContact)
602
+ }
603
+ const triple = JSON.stringify([platform, selfId, type])
604
+ const existing = byTriple.get(triple)
605
+ if (!existing || (existing.kind === 'legacy' && source === 'new')) {
606
+ byTriple.set(triple, { kind: source, entry })
607
+ }
608
+ } catch {
609
+ this.logger.warn('联系人缓存解析失败:', key)
610
+ }
611
+ }
612
+ for (const { entry } of byTriple.values()) {
613
+ result.push(entry)
614
+ }
615
+ return result
616
+ }
617
+
618
+ private async trimMessages(platform: string, selfId: string, channelId: string) {
619
+ let count = 0
620
+ const toDelete: string[] = []
621
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
622
+ for await (const [key] of this.db.iterator<string, string>({
623
+ gte: prefix,
624
+ lte: `${prefix}\uffff`,
625
+ })) {
626
+ count += 1
627
+ if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
628
+ }
629
+ }
630
+ if (!toDelete.length) return
631
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
632
+ this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
633
+ }
634
+
635
+ private async trimSelfMessages(platform: string, selfId: string, channelId: string) {
636
+ let count = 0
637
+ const toDelete: string[] = []
638
+ const prefix = selfMessagePrefix(platform, selfId, channelId)
639
+ for await (const [key] of this.db.iterator<string, string>({
640
+ gte: prefix,
641
+ lte: `${prefix}\uffff`,
642
+ })) {
643
+ count += 1
644
+ if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
645
+ }
646
+ if (!toDelete.length) return
647
+ await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
648
+ this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
649
+ }
650
+ }