koishi-plugin-chat-patch 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/vue/index.vue +8 -1
- package/client/web/dist/assets/{Chat-DTEsebnY.css → Chat-BXVWj5Bd.css} +1 -1
- package/client/web/dist/assets/{Chat-Clqxn7CB.js → Chat-m4-U5uHa.js} +4 -4
- package/client/web/dist/assets/{MsgBody-C0TKsXhC.js → MsgBody-CzrxyiFO.js} +1 -1
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +67 -0
- package/client/web/dist/assets/{index-BA37EQGa.js → index-EJ2CcODr.js} +72 -78
- package/client/web/dist/assets/index-X9hFOAza.css +1 -0
- package/client/web/dist/index.html +2 -2
- package/client/web/src/App.vue +7 -0
- package/client/web/src/assets/css/chat.css +6 -4
- package/client/web/src/assets/l10n/zh-CN.po +0 -6
- package/client/web/src/components/History.vue +27 -0
- package/client/web/src/components/MsgBody.vue +18 -9
- package/client/web/src/function/connect.ts +208 -46
- package/client/web/src/function/msg.ts +8 -6
- package/client/web/src/function/option.ts +1 -1
- package/client/web/src/function/satori-model.ts +20 -2
- package/client/web/src/function/satori.ts +64 -0
- package/client/web/src/pages/Chat.vue +0 -4
- package/client/web/src/pages/Friends.vue +7 -9
- package/client/web/src/pages/Messages.vue +6 -7
- package/client/web/src/pages/options/OptDev.vue +1 -25
- package/client/web/src/pages/options/OptFunction.vue +23 -0
- package/client/web/tsconfig.tsbuildinfo +1 -1
- package/dist/index.js +1 -1
- package/dist/style.css +1 -1
- package/lib/bootstrap.d.ts +6 -0
- package/lib/database.d.ts +5 -2
- package/lib/gateway.d.ts +46 -0
- package/lib/index.js +1468 -131
- package/lib/recorder.d.ts +3 -5
- package/lib/satori.d.ts +3 -0
- package/lib/types.d.ts +17 -0
- package/package.json +4 -1
- package/src/bootstrap.ts +5 -10
- package/src/database.ts +745 -662
- package/src/gateway.ts +259 -0
- package/src/index.ts +63 -53
- package/src/recorder.ts +97 -77
- package/src/satori.ts +19 -0
- package/src/server.d.ts +26 -26
- package/src/types.ts +19 -0
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CCdBwlN_.js +0 -67
- package/client/web/dist/assets/index-B71pPwae.css +0 -1
package/src/database.ts
CHANGED
|
@@ -1,694 +1,777 @@
|
|
|
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}`
|
|
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 isPrivateChannelType(value: unknown): boolean {
|
|
62
|
+
const num = Number(value)
|
|
63
|
+
if (Number.isFinite(num)) return num === 1
|
|
64
|
+
const text = String(value ?? '').toLowerCase()
|
|
65
|
+
return text === 'direct' || text === 'private'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isUsableGroupContact(item: ContactCacheItem): boolean {
|
|
69
|
+
if (typeof item.raw === 'object' && item.raw !== null) {
|
|
70
|
+
const raw = item.raw as Record<string, unknown>
|
|
71
|
+
if (isPrivateChannelType(raw.channel_type)) return false
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type CompactableLevel = Level<string, string> & {
|
|
77
|
+
compactRange(start: string, end: string): Promise<void>
|
|
59
78
|
}
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
interface SharedDatabase {
|
|
81
|
+
dir: string
|
|
82
|
+
db: Level<string, string>
|
|
83
|
+
refs: number
|
|
84
|
+
opened: boolean
|
|
85
|
+
opening?: Promise<void>
|
|
86
|
+
closing?: Promise<void>
|
|
66
87
|
}
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
const sharedDatabases = new Map<string, SharedDatabase>()
|
|
90
|
+
|
|
91
|
+
// 同目录复用同一个 LevelDB 实例,避免 HMR 卸载/重载期间新旧插件抢占 LOCK
|
|
92
|
+
async function acquireSharedDatabase(dir: string): Promise<SharedDatabase> {
|
|
93
|
+
let shared = sharedDatabases.get(dir)
|
|
94
|
+
if (shared?.closing) {
|
|
95
|
+
await shared.closing.catch(() => undefined)
|
|
96
|
+
shared = sharedDatabases.get(dir)
|
|
97
|
+
}
|
|
98
|
+
if (!shared) {
|
|
99
|
+
shared = {
|
|
100
|
+
dir,
|
|
101
|
+
db: new Level<string, string>(dir, { keyEncoding: 'utf8', valueEncoding: 'utf8' }),
|
|
102
|
+
refs: 0,
|
|
103
|
+
opened: false,
|
|
104
|
+
}
|
|
105
|
+
sharedDatabases.set(dir, shared)
|
|
72
106
|
}
|
|
73
|
-
|
|
107
|
+
shared.refs += 1
|
|
108
|
+
return shared
|
|
74
109
|
}
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
async function releaseSharedDatabase(shared: SharedDatabase): Promise<boolean> {
|
|
112
|
+
shared.refs -= 1
|
|
113
|
+
if (shared.refs > 0) return false
|
|
114
|
+
if (shared.closing) {
|
|
115
|
+
await shared.closing
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
if (!shared.opened) {
|
|
119
|
+
if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
const closing = shared.db.close().catch(() => undefined).finally(() => {
|
|
123
|
+
shared.opened = false
|
|
124
|
+
if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
|
|
125
|
+
})
|
|
126
|
+
shared.closing = closing
|
|
127
|
+
await closing
|
|
128
|
+
return true
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
export class ChatDatabase {
|
|
81
|
-
private
|
|
82
|
-
private
|
|
132
|
+
private readonly dir: string
|
|
133
|
+
private shared?: SharedDatabase
|
|
83
134
|
|
|
84
135
|
constructor(
|
|
85
136
|
private ctx: Context,
|
|
86
137
|
private config: Config,
|
|
87
138
|
private logger: PluginLogger,
|
|
88
139
|
) {
|
|
89
|
-
|
|
90
|
-
this.db = new Level<string, string>(dir, { keyEncoding: 'utf8', valueEncoding: 'utf8' })
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async initialize() {
|
|
94
|
-
await this.db.open()
|
|
95
|
-
this.opened = true
|
|
96
|
-
this.logger.logInfo('LevelDB 已打开:', this.db.location)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async dispose() {
|
|
100
|
-
if (!this.opened) return
|
|
101
|
-
await this.db.close()
|
|
102
|
-
this.opened = false
|
|
103
|
-
this.logger.logInfo('LevelDB closed')
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async clearAll() {
|
|
107
|
-
// 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
|
|
108
|
-
await this.db.clear()
|
|
109
|
-
const db = this.db as unknown as CompactableLevel
|
|
110
|
-
await db.compactRange('', '\uffff')
|
|
111
|
-
this.logger.logInfo('数据库缓存已全部清空并完成压缩')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async appendMessage(record: MessageRecord) {
|
|
115
|
-
await this.db.put(messageKey(record), JSON.stringify(record))
|
|
116
|
-
await this.trimMessages(record.platform, record.selfId, record.channelId || '')
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async upsertSelfMessage(record: SelfMessageRecord) {
|
|
120
|
-
if (record.messageId) {
|
|
121
|
-
await this.removeSelfMessageByMessageId(
|
|
122
|
-
record.platform,
|
|
123
|
-
record.selfId,
|
|
124
|
-
record.channelId,
|
|
125
|
-
record.messageId,
|
|
126
|
-
record.id,
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
await this.db.put(selfMessageKey(record), JSON.stringify(record))
|
|
130
|
-
await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async listSelfMessages(
|
|
134
|
-
platform: string,
|
|
135
|
-
selfId: string,
|
|
136
|
-
channelId: string,
|
|
137
|
-
limit = this.config.historyPageSize,
|
|
138
|
-
): Promise<SelfMessageRecord[]> {
|
|
139
|
-
const result: SelfMessageRecord[] = []
|
|
140
|
-
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
141
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
142
|
-
gte: prefix,
|
|
143
|
-
lte: `${prefix}\uffff`,
|
|
144
|
-
reverse: true,
|
|
145
|
-
limit,
|
|
146
|
-
})) {
|
|
147
|
-
const parsed = this.parseSelfMessage(value)
|
|
148
|
-
if (parsed) result.push(parsed)
|
|
149
|
-
}
|
|
150
|
-
return result
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async listSelfMessagesBefore(
|
|
154
|
-
platform: string,
|
|
155
|
-
selfId: string,
|
|
156
|
-
channelId: string,
|
|
157
|
-
beforeTime: number,
|
|
158
|
-
limit = this.config.historyPageSize,
|
|
159
|
-
): Promise<SelfMessageRecord[]> {
|
|
160
|
-
const result: SelfMessageRecord[] = []
|
|
161
|
-
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
162
|
-
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
163
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
164
|
-
gte: prefix,
|
|
165
|
-
lt: before,
|
|
166
|
-
reverse: true,
|
|
167
|
-
limit,
|
|
168
|
-
})) {
|
|
169
|
-
const parsed = this.parseSelfMessage(value)
|
|
170
|
-
if (parsed) result.push(parsed)
|
|
171
|
-
}
|
|
172
|
-
return result
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async updateSelfMessageByMessageId(
|
|
176
|
-
platform: string,
|
|
177
|
-
selfId: string,
|
|
178
|
-
_channelId: string,
|
|
179
|
-
messageId: string,
|
|
180
|
-
patch: Partial<SelfMessageRecord>,
|
|
181
|
-
): Promise<boolean> {
|
|
182
|
-
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
183
|
-
for await (const [key, value] of this.db.iterator<string, string>({
|
|
184
|
-
gte: prefix,
|
|
185
|
-
lte: `${prefix}\uffff`,
|
|
186
|
-
})) {
|
|
187
|
-
const parsed = this.parseSelfMessage(value)
|
|
188
|
-
if (!parsed || parsed.messageId !== messageId) continue
|
|
189
|
-
const next: SelfMessageRecord = {
|
|
190
|
-
...parsed,
|
|
191
|
-
...patch,
|
|
192
|
-
id: parsed.id,
|
|
193
|
-
sentAt: parsed.sentAt,
|
|
194
|
-
}
|
|
195
|
-
await this.db.put(key, JSON.stringify(next))
|
|
196
|
-
return true
|
|
197
|
-
}
|
|
198
|
-
return false
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async updateMessageRevoked(
|
|
202
|
-
platform: string,
|
|
203
|
-
selfId: string,
|
|
204
|
-
channelId: string,
|
|
205
|
-
messageId: string,
|
|
206
|
-
patch: { revoked: boolean; revokedAt: number },
|
|
207
|
-
): Promise<boolean> {
|
|
208
|
-
const operations: Array<{ type: 'put'; key: string; value: string }> = []
|
|
209
|
-
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
210
|
-
for await (const [key, value] of this.db.iterator<string, string>({
|
|
211
|
-
gte: prefix,
|
|
212
|
-
lte: `${prefix}\uffff`,
|
|
213
|
-
})) {
|
|
214
|
-
try {
|
|
215
|
-
const parsed = JSON.parse(value) as unknown
|
|
216
|
-
if (typeof parsed !== 'object' || parsed === null) continue
|
|
217
|
-
const record = parsed as Partial<MessageRecord>
|
|
218
|
-
const raw = typeof record.raw === 'object' && record.raw !== null
|
|
219
|
-
? record.raw as Record<string, unknown>
|
|
220
|
-
: {}
|
|
221
|
-
const rawMessage = typeof raw.message === 'object' && raw.message !== null
|
|
222
|
-
? raw.message as Record<string, unknown>
|
|
223
|
-
: {}
|
|
224
|
-
const id = String(record.id ?? rawMessage.id ?? '')
|
|
225
|
-
if (!id || id !== messageId) continue
|
|
226
|
-
operations.push({
|
|
227
|
-
type: 'put',
|
|
228
|
-
key,
|
|
229
|
-
value: JSON.stringify({ ...parsed, ...patch }),
|
|
230
|
-
})
|
|
231
|
-
} catch {
|
|
232
|
-
// 单条解析失败不影响其他消息
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
if (operations.length) await this.db.batch(operations)
|
|
237
|
-
return operations.length > 0
|
|
140
|
+
this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
|
|
238
141
|
}
|
|
239
142
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
channelId: string,
|
|
244
|
-
id: string,
|
|
245
|
-
): Promise<unknown[] | null> {
|
|
246
|
-
const prefixes = channelId
|
|
247
|
-
? [selfMessagePrefix(platform, selfId, channelId)]
|
|
248
|
-
: [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
|
|
249
|
-
for (const prefix of prefixes) {
|
|
250
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
251
|
-
gte: prefix,
|
|
252
|
-
lte: `${prefix}\uffff`,
|
|
253
|
-
})) {
|
|
254
|
-
const parsed = this.parseSelfMessage(value)
|
|
255
|
-
if (!parsed) continue
|
|
256
|
-
if (parsed.forwardId === id || parsed.messageId === id) {
|
|
257
|
-
if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
|
|
258
|
-
return parsed.forwardContent
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return null
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
private async removeSelfMessageByMessageId(
|
|
267
|
-
platform: string,
|
|
268
|
-
selfId: string,
|
|
269
|
-
_channelId: string,
|
|
270
|
-
messageId: string,
|
|
271
|
-
exceptId: string,
|
|
272
|
-
) {
|
|
273
|
-
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
274
|
-
const toDelete: string[] = []
|
|
275
|
-
for await (const [key, value] of this.db.iterator<string, string>({
|
|
276
|
-
gte: prefix,
|
|
277
|
-
lte: `${prefix}\uffff`,
|
|
278
|
-
})) {
|
|
279
|
-
const parsed = this.parseSelfMessage(value)
|
|
280
|
-
if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
|
|
281
|
-
toDelete.push(key)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
if (toDelete.length) {
|
|
285
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
286
|
-
}
|
|
143
|
+
private get db(): Level<string, string> {
|
|
144
|
+
if (!this.shared) throw new Error('LevelDB is not initialized')
|
|
145
|
+
return this.shared.db
|
|
287
146
|
}
|
|
288
147
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const parsed = JSON.parse(value) as unknown
|
|
292
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
293
|
-
const record = parsed as Partial<SelfMessageRecord>
|
|
294
|
-
if (typeof record.id === 'string'
|
|
295
|
-
&& typeof record.platform === 'string'
|
|
296
|
-
&& typeof record.selfId === 'string'
|
|
297
|
-
&& typeof record.channelId === 'string'
|
|
298
|
-
&& typeof record.sentAt === 'number') {
|
|
299
|
-
return record as SelfMessageRecord
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
} catch {
|
|
303
|
-
this.logger.warn('机器人消息解析失败:', value.slice(0, 120))
|
|
304
|
-
}
|
|
305
|
-
return null
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async listMessages(
|
|
309
|
-
platform: string,
|
|
310
|
-
selfId: string,
|
|
311
|
-
channelId: string,
|
|
312
|
-
limit = this.config.historyPageSize,
|
|
313
|
-
): Promise<MessageRecord[]> {
|
|
314
|
-
const result: MessageRecord[] = []
|
|
315
|
-
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
316
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
317
|
-
gte: prefix,
|
|
318
|
-
lte: `${prefix}\uffff`,
|
|
319
|
-
reverse: true,
|
|
320
|
-
limit: limit - result.length,
|
|
321
|
-
})) {
|
|
322
|
-
try {
|
|
323
|
-
const parsed = JSON.parse(value) as unknown
|
|
324
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
325
|
-
result.push(parsed as MessageRecord)
|
|
326
|
-
}
|
|
327
|
-
} catch {
|
|
328
|
-
this.logger.warn('历史消息解析失败:', value.slice(0, 120))
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (result.length >= limit) break
|
|
332
|
-
}
|
|
333
|
-
return result
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
async listMessagesBefore(
|
|
337
|
-
platform: string,
|
|
338
|
-
selfId: string,
|
|
339
|
-
channelId: string,
|
|
340
|
-
beforeTime: number,
|
|
341
|
-
limit = this.config.historyPageSize,
|
|
342
|
-
): Promise<MessageRecord[]> {
|
|
343
|
-
const result: MessageRecord[] = []
|
|
344
|
-
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
345
|
-
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
346
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
347
|
-
gte: prefix,
|
|
348
|
-
lt: before,
|
|
349
|
-
reverse: true,
|
|
350
|
-
limit: limit - result.length,
|
|
351
|
-
})) {
|
|
352
|
-
try {
|
|
353
|
-
const parsed = JSON.parse(value) as unknown
|
|
354
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
355
|
-
result.push(parsed as MessageRecord)
|
|
356
|
-
}
|
|
357
|
-
} catch {
|
|
358
|
-
this.logger.warn('历史消息解析失败:', value.slice(0, 120))
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
if (result.length >= limit) break
|
|
362
|
-
}
|
|
363
|
-
return result
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async clearChannel(platform: string, selfId: string, channelId: string) {
|
|
367
|
-
const operations: Array<{ type: 'del'; key: string }> = []
|
|
368
|
-
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
369
|
-
for await (const [key] of this.db.iterator<string, string>({
|
|
370
|
-
gte: prefix,
|
|
371
|
-
lte: `${prefix}\uffff`,
|
|
372
|
-
})) {
|
|
373
|
-
operations.push({ type: 'del', key })
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
|
|
377
|
-
for await (const [key] of this.db.iterator<string, string>({
|
|
378
|
-
gte: selfPrefix,
|
|
379
|
-
lte: `${selfPrefix}\uffff`,
|
|
380
|
-
})) {
|
|
381
|
-
operations.push({ type: 'del', key })
|
|
382
|
-
}
|
|
383
|
-
if (operations.length) await this.db.batch(operations)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async getPinned(): Promise<PinnedState> {
|
|
387
|
-
try {
|
|
388
|
-
const value = await this.db.get('meta:pinned')
|
|
389
|
-
const parsed = JSON.parse(value) as unknown
|
|
390
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
391
|
-
return parsed as PinnedState
|
|
392
|
-
}
|
|
393
|
-
} catch {
|
|
394
|
-
// 首次使用没有置顶数据
|
|
395
|
-
}
|
|
396
|
-
return { bots: [], channels: [] }
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async setPinned(state: PinnedState) {
|
|
400
|
-
await this.db.put('meta:pinned', JSON.stringify(state))
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
async getMeta(key: string): Promise<unknown> {
|
|
404
|
-
try {
|
|
405
|
-
return JSON.parse(await this.db.get(`meta:${key}`)) as unknown
|
|
406
|
-
} catch {
|
|
407
|
-
return undefined
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async setMeta(key: string, value: unknown) {
|
|
412
|
-
await this.db.put(`meta:${key}`, JSON.stringify(value))
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async recordMedia(url: string, filePath: string, channelId = '') {
|
|
416
|
-
const key = `media:${createHash('md5').update(url).digest('hex')}`
|
|
417
|
-
await this.db.put(key, JSON.stringify({ filePath, channelId }))
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async getMediaPath(url: string): Promise<string | undefined> {
|
|
148
|
+
async initialize() {
|
|
149
|
+
this.shared = await acquireSharedDatabase(this.dir)
|
|
421
150
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const item = parsed as { filePath?: unknown }
|
|
428
|
-
return typeof item.filePath === 'string' ? item.filePath : value
|
|
429
|
-
}
|
|
430
|
-
} catch {
|
|
431
|
-
// 旧记录直接存的是文件路径
|
|
432
|
-
}
|
|
433
|
-
return value
|
|
434
|
-
} catch {
|
|
435
|
-
return undefined
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
|
|
440
|
-
const result: Array<{ filePath: string; channelId: string }> = []
|
|
441
|
-
for await (const [, value] of this.db.iterator<string, string>({
|
|
442
|
-
gte: 'media:',
|
|
443
|
-
lte: 'media:\uffff',
|
|
444
|
-
})) {
|
|
445
|
-
try {
|
|
446
|
-
const parsed = JSON.parse(value) as unknown
|
|
447
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
448
|
-
const item = parsed as { filePath?: unknown; channelId?: unknown }
|
|
449
|
-
if (typeof item.filePath === 'string') {
|
|
450
|
-
result.push({
|
|
451
|
-
filePath: item.filePath,
|
|
452
|
-
channelId: typeof item.channelId === 'string' ? item.channelId : '',
|
|
453
|
-
})
|
|
454
|
-
}
|
|
455
|
-
} else if (typeof parsed === 'string') {
|
|
456
|
-
result.push({ filePath: parsed, channelId: '' })
|
|
457
|
-
}
|
|
458
|
-
} catch {
|
|
459
|
-
result.push({ filePath: value, channelId: '' })
|
|
460
|
-
}
|
|
151
|
+
await this.ensureOpen()
|
|
152
|
+
this.logger.logInfo('LevelDB 已打开:', this.shared.db.location)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await this.releaseShared()
|
|
155
|
+
throw error
|
|
461
156
|
}
|
|
462
|
-
return result
|
|
463
157
|
}
|
|
464
158
|
|
|
465
|
-
async
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
for await (const [key, value] of this.db.iterator<string, string>({
|
|
469
|
-
gte: 'media:',
|
|
470
|
-
lte: 'media:\uffff',
|
|
471
|
-
})) {
|
|
472
|
-
let stored = value
|
|
473
|
-
try {
|
|
474
|
-
const parsed = JSON.parse(value) as unknown
|
|
475
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
476
|
-
const item = parsed as { filePath?: unknown }
|
|
477
|
-
stored = typeof item.filePath === 'string' ? item.filePath : value
|
|
478
|
-
}
|
|
479
|
-
} catch {
|
|
480
|
-
// 旧格式直接存路径
|
|
481
|
-
}
|
|
482
|
-
if (path.normalize(stored) === normalized) toDelete.push(key)
|
|
483
|
-
}
|
|
484
|
-
if (toDelete.length) {
|
|
485
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
486
|
-
}
|
|
159
|
+
async dispose() {
|
|
160
|
+
const closed = await this.releaseShared()
|
|
161
|
+
if (closed) this.logger.logInfo('LevelDB closed')
|
|
487
162
|
}
|
|
488
163
|
|
|
489
|
-
async
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const contacts = parsed as ContactCacheItem[]
|
|
496
|
-
return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
497
|
-
}
|
|
498
|
-
} catch {
|
|
499
|
-
// 旧 key 或首次使用可能不存在
|
|
500
|
-
}
|
|
164
|
+
private async ensureOpen() {
|
|
165
|
+
const shared = this.shared
|
|
166
|
+
if (!shared || shared.opened) return
|
|
167
|
+
if (shared.opening) {
|
|
168
|
+
await shared.opening
|
|
169
|
+
return
|
|
501
170
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
platform: string,
|
|
507
|
-
selfId: string,
|
|
508
|
-
type: string,
|
|
509
|
-
id: string,
|
|
510
|
-
): Promise<ContactCacheItem | null> {
|
|
511
|
-
const contacts = await this.getContacts(platform, selfId, type)
|
|
512
|
-
return contacts.find((item) => item.id === id) ?? null
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async setContacts(platform: string, selfId: string, type: string, contacts: ContactCacheItem[]) {
|
|
516
|
-
const next = type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
517
|
-
await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next))
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async appendContact(platform: string, selfId: string, type: string, contact: ContactCacheItem) {
|
|
521
|
-
if (type === 'group' && !isUsableGroupContact(contact)) return
|
|
522
|
-
const contacts = await this.getContacts(platform, selfId, type)
|
|
523
|
-
const next = contacts.filter((item) => item.id !== contact.id)
|
|
524
|
-
next.push(contact)
|
|
525
|
-
await this.setContacts(platform, selfId, type, next)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
async getContactsLegacy(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
|
|
171
|
+
const opening = shared.db.open().then(() => {
|
|
172
|
+
shared.opened = true
|
|
173
|
+
})
|
|
174
|
+
shared.opening = opening
|
|
529
175
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const contacts = parsed as ContactCacheItem[]
|
|
534
|
-
return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
535
|
-
} catch {
|
|
536
|
-
return []
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async getGroupMembers(
|
|
541
|
-
platform: string,
|
|
542
|
-
selfId: string,
|
|
543
|
-
groupId: string,
|
|
544
|
-
): Promise<ContactCacheItem[]> {
|
|
545
|
-
for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
|
|
546
|
-
try {
|
|
547
|
-
const value = await this.db.get(key)
|
|
548
|
-
const parsed = JSON.parse(value) as unknown
|
|
549
|
-
return Array.isArray(parsed) ? parsed as ContactCacheItem[] : []
|
|
550
|
-
} catch {
|
|
551
|
-
// 新键或旧键可能不存在,继续尝试另一个
|
|
552
|
-
}
|
|
176
|
+
await opening
|
|
177
|
+
} finally {
|
|
178
|
+
shared.opening = undefined
|
|
553
179
|
}
|
|
554
|
-
return []
|
|
555
180
|
}
|
|
556
181
|
|
|
557
|
-
async
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
) {
|
|
563
|
-
await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members))
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
async getGroupMember(
|
|
567
|
-
platform: string,
|
|
568
|
-
selfId: string,
|
|
569
|
-
groupId: string,
|
|
570
|
-
userId: string,
|
|
571
|
-
): Promise<ContactCacheItem | null> {
|
|
572
|
-
const members = await this.getGroupMembers(platform, selfId, groupId)
|
|
573
|
-
return members.find((item) => item.id === userId) ?? null
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async appendGroupMember(
|
|
577
|
-
platform: string,
|
|
578
|
-
selfId: string,
|
|
579
|
-
groupId: string,
|
|
580
|
-
member: ContactCacheItem,
|
|
581
|
-
) {
|
|
582
|
-
const members = await this.getGroupMembers(platform, selfId, groupId)
|
|
583
|
-
const next = members.filter((item) => item.id !== member.id)
|
|
584
|
-
next.push(member)
|
|
585
|
-
await this.setGroupMembers(platform, selfId, groupId, next)
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
async getAllContacts(): Promise<Array<{
|
|
589
|
-
platform: string
|
|
590
|
-
selfId: string
|
|
591
|
-
type: string
|
|
592
|
-
contacts: ContactCacheItem[]
|
|
593
|
-
}>> {
|
|
594
|
-
type ContactEntry = {
|
|
595
|
-
platform: string
|
|
596
|
-
selfId: string
|
|
597
|
-
type: string
|
|
598
|
-
contacts: ContactCacheItem[]
|
|
599
|
-
}
|
|
600
|
-
const result: Array<{
|
|
601
|
-
platform: string
|
|
602
|
-
selfId: string
|
|
603
|
-
type: string
|
|
604
|
-
contacts: ContactCacheItem[]
|
|
605
|
-
}> = []
|
|
606
|
-
const byTriple = new Map<string, {
|
|
607
|
-
kind: 'new' | 'legacy'
|
|
608
|
-
entry: ContactEntry
|
|
609
|
-
}>()
|
|
610
|
-
for await (const [key, value] of this.db.iterator<string, string>({
|
|
611
|
-
gte: 'c:',
|
|
612
|
-
lte: 'c:\uffff',
|
|
613
|
-
})) {
|
|
614
|
-
if (!key.startsWith('c:')) continue
|
|
615
|
-
let source: 'new' | 'legacy' = 'legacy'
|
|
616
|
-
let platform = ''
|
|
617
|
-
let selfId = ''
|
|
618
|
-
let type = ''
|
|
619
|
-
const parts = key.slice(2).split(':')
|
|
620
|
-
if (parts.length === 3) {
|
|
621
|
-
platform = decodeKeyPart(parts[0])
|
|
622
|
-
selfId = decodeKeyPart(parts[1])
|
|
623
|
-
type = decodeKeyPart(parts[2])
|
|
624
|
-
source = 'new'
|
|
625
|
-
} else {
|
|
626
|
-
const typeIndex = key.lastIndexOf(':')
|
|
627
|
-
if (typeIndex <= 2) continue
|
|
628
|
-
type = key.slice(typeIndex + 1)
|
|
629
|
-
const rest = key.slice(2, typeIndex)
|
|
630
|
-
const sep = rest.lastIndexOf(':')
|
|
631
|
-
if (sep <= 0) continue
|
|
632
|
-
platform = rest.slice(0, sep)
|
|
633
|
-
selfId = rest.slice(sep + 1)
|
|
634
|
-
}
|
|
635
|
-
if (!platform || !selfId || !type) continue
|
|
636
|
-
try {
|
|
637
|
-
const parsed = JSON.parse(value) as unknown
|
|
638
|
-
const entry: ContactEntry = {
|
|
639
|
-
platform,
|
|
640
|
-
selfId,
|
|
641
|
-
type,
|
|
642
|
-
contacts: Array.isArray(parsed) ? parsed as ContactCacheItem[] : [],
|
|
643
|
-
}
|
|
644
|
-
if (entry.type === 'group') {
|
|
645
|
-
entry.contacts = entry.contacts.filter(isUsableGroupContact)
|
|
646
|
-
}
|
|
647
|
-
const triple = JSON.stringify([platform, selfId, type])
|
|
648
|
-
const existing = byTriple.get(triple)
|
|
649
|
-
if (!existing || (existing.kind === 'legacy' && source === 'new')) {
|
|
650
|
-
byTriple.set(triple, { kind: source, entry })
|
|
651
|
-
}
|
|
652
|
-
} catch {
|
|
653
|
-
this.logger.warn('联系人缓存解析失败:', key)
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
for (const { entry } of byTriple.values()) {
|
|
657
|
-
result.push(entry)
|
|
658
|
-
}
|
|
659
|
-
return result
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
private async trimMessages(platform: string, selfId: string, channelId: string) {
|
|
663
|
-
let count = 0
|
|
664
|
-
const toDelete: string[] = []
|
|
665
|
-
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
666
|
-
for await (const [key] of this.db.iterator<string, string>({
|
|
667
|
-
gte: prefix,
|
|
668
|
-
lte: `${prefix}\uffff`,
|
|
669
|
-
})) {
|
|
670
|
-
count += 1
|
|
671
|
-
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
if (!toDelete.length) return
|
|
675
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
676
|
-
this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
private async trimSelfMessages(platform: string, selfId: string, channelId: string) {
|
|
680
|
-
let count = 0
|
|
681
|
-
const toDelete: string[] = []
|
|
682
|
-
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
683
|
-
for await (const [key] of this.db.iterator<string, string>({
|
|
684
|
-
gte: prefix,
|
|
685
|
-
lte: `${prefix}\uffff`,
|
|
686
|
-
})) {
|
|
687
|
-
count += 1
|
|
688
|
-
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
689
|
-
}
|
|
690
|
-
if (!toDelete.length) return
|
|
691
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
692
|
-
this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
|
|
182
|
+
private async releaseShared(): Promise<boolean> {
|
|
183
|
+
const shared = this.shared
|
|
184
|
+
this.shared = undefined
|
|
185
|
+
if (shared) return releaseSharedDatabase(shared)
|
|
186
|
+
return false
|
|
693
187
|
}
|
|
694
|
-
|
|
188
|
+
|
|
189
|
+
async clearAll() {
|
|
190
|
+
// 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
|
|
191
|
+
await this.db.clear()
|
|
192
|
+
const db = this.db as unknown as CompactableLevel
|
|
193
|
+
await db.compactRange('', '\uffff')
|
|
194
|
+
this.logger.logInfo('数据库缓存已全部清空并完成压缩')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async appendMessage(record: MessageRecord) {
|
|
198
|
+
await this.db.put(messageKey(record), JSON.stringify(record))
|
|
199
|
+
await this.trimMessages(record.platform, record.selfId, record.channelId || '')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async upsertSelfMessage(record: SelfMessageRecord) {
|
|
203
|
+
if (record.messageId) {
|
|
204
|
+
await this.removeSelfMessageByMessageId(
|
|
205
|
+
record.platform,
|
|
206
|
+
record.selfId,
|
|
207
|
+
record.channelId,
|
|
208
|
+
record.messageId,
|
|
209
|
+
record.id,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
await this.db.put(selfMessageKey(record), JSON.stringify(record))
|
|
213
|
+
await this.trimSelfMessages(record.platform, record.selfId, record.channelId)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async listSelfMessages(
|
|
217
|
+
platform: string,
|
|
218
|
+
selfId: string,
|
|
219
|
+
channelId: string,
|
|
220
|
+
limit = this.config.historyPageSize,
|
|
221
|
+
): Promise<SelfMessageRecord[]> {
|
|
222
|
+
const result: SelfMessageRecord[] = []
|
|
223
|
+
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
224
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
225
|
+
gte: prefix,
|
|
226
|
+
lte: `${prefix}\uffff`,
|
|
227
|
+
reverse: true,
|
|
228
|
+
limit,
|
|
229
|
+
})) {
|
|
230
|
+
const parsed = this.parseSelfMessage(value)
|
|
231
|
+
if (parsed) result.push(parsed)
|
|
232
|
+
}
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async listSelfMessagesBefore(
|
|
237
|
+
platform: string,
|
|
238
|
+
selfId: string,
|
|
239
|
+
channelId: string,
|
|
240
|
+
beforeTime: number,
|
|
241
|
+
limit = this.config.historyPageSize,
|
|
242
|
+
): Promise<SelfMessageRecord[]> {
|
|
243
|
+
const result: SelfMessageRecord[] = []
|
|
244
|
+
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
245
|
+
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
246
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
247
|
+
gte: prefix,
|
|
248
|
+
lt: before,
|
|
249
|
+
reverse: true,
|
|
250
|
+
limit,
|
|
251
|
+
})) {
|
|
252
|
+
const parsed = this.parseSelfMessage(value)
|
|
253
|
+
if (parsed) result.push(parsed)
|
|
254
|
+
}
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async updateSelfMessageByMessageId(
|
|
259
|
+
platform: string,
|
|
260
|
+
selfId: string,
|
|
261
|
+
_channelId: string,
|
|
262
|
+
messageId: string,
|
|
263
|
+
patch: Partial<SelfMessageRecord>,
|
|
264
|
+
): Promise<boolean> {
|
|
265
|
+
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
266
|
+
for await (const [key, value] of this.db.iterator<string, string>({
|
|
267
|
+
gte: prefix,
|
|
268
|
+
lte: `${prefix}\uffff`,
|
|
269
|
+
})) {
|
|
270
|
+
const parsed = this.parseSelfMessage(value)
|
|
271
|
+
if (!parsed || parsed.messageId !== messageId) continue
|
|
272
|
+
const next: SelfMessageRecord = {
|
|
273
|
+
...parsed,
|
|
274
|
+
...patch,
|
|
275
|
+
id: parsed.id,
|
|
276
|
+
sentAt: parsed.sentAt,
|
|
277
|
+
}
|
|
278
|
+
await this.db.put(key, JSON.stringify(next))
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async updateMessageRevoked(
|
|
285
|
+
platform: string,
|
|
286
|
+
selfId: string,
|
|
287
|
+
channelId: string,
|
|
288
|
+
messageId: string,
|
|
289
|
+
patch: { revoked: boolean; revokedAt: number },
|
|
290
|
+
): Promise<boolean> {
|
|
291
|
+
const operations: Array<{ type: 'put'; key: string; value: string }> = []
|
|
292
|
+
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
293
|
+
for await (const [key, value] of this.db.iterator<string, string>({
|
|
294
|
+
gte: prefix,
|
|
295
|
+
lte: `${prefix}\uffff`,
|
|
296
|
+
})) {
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(value) as unknown
|
|
299
|
+
if (typeof parsed !== 'object' || parsed === null) continue
|
|
300
|
+
const record = parsed as Partial<MessageRecord>
|
|
301
|
+
const raw = typeof record.raw === 'object' && record.raw !== null
|
|
302
|
+
? record.raw as Record<string, unknown>
|
|
303
|
+
: {}
|
|
304
|
+
const rawMessage = typeof raw.message === 'object' && raw.message !== null
|
|
305
|
+
? raw.message as Record<string, unknown>
|
|
306
|
+
: {}
|
|
307
|
+
const id = String(record.id ?? rawMessage.id ?? '')
|
|
308
|
+
if (!id || id !== messageId) continue
|
|
309
|
+
operations.push({
|
|
310
|
+
type: 'put',
|
|
311
|
+
key,
|
|
312
|
+
value: JSON.stringify({ ...parsed, ...patch }),
|
|
313
|
+
})
|
|
314
|
+
} catch {
|
|
315
|
+
// 单条解析失败不影响其他消息
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (operations.length) await this.db.batch(operations)
|
|
320
|
+
return operations.length > 0
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async findSelfForwardContent(
|
|
324
|
+
platform: string,
|
|
325
|
+
selfId: string,
|
|
326
|
+
channelId: string,
|
|
327
|
+
id: string,
|
|
328
|
+
): Promise<unknown[] | null> {
|
|
329
|
+
const prefixes = channelId
|
|
330
|
+
? [selfMessagePrefix(platform, selfId, channelId)]
|
|
331
|
+
: [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
|
|
332
|
+
for (const prefix of prefixes) {
|
|
333
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
334
|
+
gte: prefix,
|
|
335
|
+
lte: `${prefix}\uffff`,
|
|
336
|
+
})) {
|
|
337
|
+
const parsed = this.parseSelfMessage(value)
|
|
338
|
+
if (!parsed) continue
|
|
339
|
+
if (parsed.forwardId === id || parsed.messageId === id) {
|
|
340
|
+
if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
|
|
341
|
+
return parsed.forwardContent
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async removeSelfMessageByMessageId(
|
|
350
|
+
platform: string,
|
|
351
|
+
selfId: string,
|
|
352
|
+
_channelId: string,
|
|
353
|
+
messageId: string,
|
|
354
|
+
exceptId: string,
|
|
355
|
+
) {
|
|
356
|
+
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
357
|
+
const toDelete: string[] = []
|
|
358
|
+
for await (const [key, value] of this.db.iterator<string, string>({
|
|
359
|
+
gte: prefix,
|
|
360
|
+
lte: `${prefix}\uffff`,
|
|
361
|
+
})) {
|
|
362
|
+
const parsed = this.parseSelfMessage(value)
|
|
363
|
+
if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
|
|
364
|
+
toDelete.push(key)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (toDelete.length) {
|
|
368
|
+
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private parseSelfMessage(value: string): SelfMessageRecord | null {
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(value) as unknown
|
|
375
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
376
|
+
const record = parsed as Partial<SelfMessageRecord>
|
|
377
|
+
if (typeof record.id === 'string'
|
|
378
|
+
&& typeof record.platform === 'string'
|
|
379
|
+
&& typeof record.selfId === 'string'
|
|
380
|
+
&& typeof record.channelId === 'string'
|
|
381
|
+
&& typeof record.sentAt === 'number') {
|
|
382
|
+
return record as SelfMessageRecord
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
this.logger.warn('机器人消息解析失败:', value.slice(0, 120))
|
|
387
|
+
}
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async listMessages(
|
|
392
|
+
platform: string,
|
|
393
|
+
selfId: string,
|
|
394
|
+
channelId: string,
|
|
395
|
+
limit = this.config.historyPageSize,
|
|
396
|
+
): Promise<MessageRecord[]> {
|
|
397
|
+
const result: MessageRecord[] = []
|
|
398
|
+
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
399
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
400
|
+
gte: prefix,
|
|
401
|
+
lte: `${prefix}\uffff`,
|
|
402
|
+
reverse: true,
|
|
403
|
+
limit: limit - result.length,
|
|
404
|
+
})) {
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(value) as unknown
|
|
407
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
408
|
+
result.push(parsed as MessageRecord)
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
this.logger.warn('历史消息解析失败:', value.slice(0, 120))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (result.length >= limit) break
|
|
415
|
+
}
|
|
416
|
+
return result
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async listMessagesBefore(
|
|
420
|
+
platform: string,
|
|
421
|
+
selfId: string,
|
|
422
|
+
channelId: string,
|
|
423
|
+
beforeTime: number,
|
|
424
|
+
limit = this.config.historyPageSize,
|
|
425
|
+
): Promise<MessageRecord[]> {
|
|
426
|
+
const result: MessageRecord[] = []
|
|
427
|
+
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
428
|
+
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
429
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
430
|
+
gte: prefix,
|
|
431
|
+
lt: before,
|
|
432
|
+
reverse: true,
|
|
433
|
+
limit: limit - result.length,
|
|
434
|
+
})) {
|
|
435
|
+
try {
|
|
436
|
+
const parsed = JSON.parse(value) as unknown
|
|
437
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
438
|
+
result.push(parsed as MessageRecord)
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
this.logger.warn('历史消息解析失败:', value.slice(0, 120))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (result.length >= limit) break
|
|
445
|
+
}
|
|
446
|
+
return result
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async clearChannel(platform: string, selfId: string, channelId: string) {
|
|
450
|
+
const operations: Array<{ type: 'del'; key: string }> = []
|
|
451
|
+
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
452
|
+
for await (const [key] of this.db.iterator<string, string>({
|
|
453
|
+
gte: prefix,
|
|
454
|
+
lte: `${prefix}\uffff`,
|
|
455
|
+
})) {
|
|
456
|
+
operations.push({ type: 'del', key })
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
|
|
460
|
+
for await (const [key] of this.db.iterator<string, string>({
|
|
461
|
+
gte: selfPrefix,
|
|
462
|
+
lte: `${selfPrefix}\uffff`,
|
|
463
|
+
})) {
|
|
464
|
+
operations.push({ type: 'del', key })
|
|
465
|
+
}
|
|
466
|
+
if (operations.length) await this.db.batch(operations)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async getPinned(): Promise<PinnedState> {
|
|
470
|
+
try {
|
|
471
|
+
const value = await this.db.get('meta:pinned')
|
|
472
|
+
const parsed = JSON.parse(value) as unknown
|
|
473
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
474
|
+
return parsed as PinnedState
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
// 首次使用没有置顶数据
|
|
478
|
+
}
|
|
479
|
+
return { bots: [], channels: [] }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async setPinned(state: PinnedState) {
|
|
483
|
+
await this.db.put('meta:pinned', JSON.stringify(state))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async getMeta(key: string): Promise<unknown> {
|
|
487
|
+
try {
|
|
488
|
+
return JSON.parse(await this.db.get(`meta:${key}`)) as unknown
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async setMeta(key: string, value: unknown) {
|
|
495
|
+
await this.db.put(`meta:${key}`, JSON.stringify(value))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async recordMedia(url: string, filePath: string, channelId = '') {
|
|
499
|
+
const key = `media:${createHash('md5').update(url).digest('hex')}`
|
|
500
|
+
await this.db.put(key, JSON.stringify({ filePath, channelId }))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async getMediaPath(url: string): Promise<string | undefined> {
|
|
504
|
+
try {
|
|
505
|
+
const key = `media:${createHash('md5').update(url).digest('hex')}`
|
|
506
|
+
const value = await this.db.get(key)
|
|
507
|
+
try {
|
|
508
|
+
const parsed = JSON.parse(value) as unknown
|
|
509
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
510
|
+
const item = parsed as { filePath?: unknown }
|
|
511
|
+
return typeof item.filePath === 'string' ? item.filePath : value
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
// 旧记录直接存的是文件路径
|
|
515
|
+
}
|
|
516
|
+
return value
|
|
517
|
+
} catch {
|
|
518
|
+
return undefined
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
|
|
523
|
+
const result: Array<{ filePath: string; channelId: string }> = []
|
|
524
|
+
for await (const [, value] of this.db.iterator<string, string>({
|
|
525
|
+
gte: 'media:',
|
|
526
|
+
lte: 'media:\uffff',
|
|
527
|
+
})) {
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(value) as unknown
|
|
530
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
531
|
+
const item = parsed as { filePath?: unknown; channelId?: unknown }
|
|
532
|
+
if (typeof item.filePath === 'string') {
|
|
533
|
+
result.push({
|
|
534
|
+
filePath: item.filePath,
|
|
535
|
+
channelId: typeof item.channelId === 'string' ? item.channelId : '',
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
} else if (typeof parsed === 'string') {
|
|
539
|
+
result.push({ filePath: parsed, channelId: '' })
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
result.push({ filePath: value, channelId: '' })
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return result
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async removeMediaByPath(filePath: string) {
|
|
549
|
+
const normalized = path.normalize(filePath)
|
|
550
|
+
const toDelete: string[] = []
|
|
551
|
+
for await (const [key, value] of this.db.iterator<string, string>({
|
|
552
|
+
gte: 'media:',
|
|
553
|
+
lte: 'media:\uffff',
|
|
554
|
+
})) {
|
|
555
|
+
let stored = value
|
|
556
|
+
try {
|
|
557
|
+
const parsed = JSON.parse(value) as unknown
|
|
558
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
559
|
+
const item = parsed as { filePath?: unknown }
|
|
560
|
+
stored = typeof item.filePath === 'string' ? item.filePath : value
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// 旧格式直接存路径
|
|
564
|
+
}
|
|
565
|
+
if (path.normalize(stored) === normalized) toDelete.push(key)
|
|
566
|
+
}
|
|
567
|
+
if (toDelete.length) {
|
|
568
|
+
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async getContacts(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
|
|
573
|
+
for (const key of [contactKey(platform, selfId, type), legacyContactKey(platform, selfId, type)]) {
|
|
574
|
+
try {
|
|
575
|
+
const value = await this.db.get(key)
|
|
576
|
+
const parsed = JSON.parse(value) as unknown
|
|
577
|
+
if (Array.isArray(parsed)) {
|
|
578
|
+
const contacts = parsed as ContactCacheItem[]
|
|
579
|
+
return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// 旧 key 或首次使用可能不存在
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return []
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async getContact(
|
|
589
|
+
platform: string,
|
|
590
|
+
selfId: string,
|
|
591
|
+
type: string,
|
|
592
|
+
id: string,
|
|
593
|
+
): Promise<ContactCacheItem | null> {
|
|
594
|
+
const contacts = await this.getContacts(platform, selfId, type)
|
|
595
|
+
return contacts.find((item) => item.id === id) ?? null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async setContacts(platform: string, selfId: string, type: string, contacts: ContactCacheItem[]) {
|
|
599
|
+
const next = type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
600
|
+
await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next))
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async appendContact(platform: string, selfId: string, type: string, contact: ContactCacheItem) {
|
|
604
|
+
if (type === 'group' && !isUsableGroupContact(contact)) return
|
|
605
|
+
const contacts = await this.getContacts(platform, selfId, type)
|
|
606
|
+
const next = contacts.filter((item) => item.id !== contact.id)
|
|
607
|
+
next.push(contact)
|
|
608
|
+
await this.setContacts(platform, selfId, type, next)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async getContactsLegacy(platform: string, selfId: string, type: string): Promise<ContactCacheItem[]> {
|
|
612
|
+
try {
|
|
613
|
+
const value = await this.db.get(legacyContactKey(platform, selfId, type))
|
|
614
|
+
const parsed = JSON.parse(value) as unknown
|
|
615
|
+
if (!Array.isArray(parsed)) return []
|
|
616
|
+
const contacts = parsed as ContactCacheItem[]
|
|
617
|
+
return type === 'group' ? contacts.filter(isUsableGroupContact) : contacts
|
|
618
|
+
} catch {
|
|
619
|
+
return []
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async getGroupMembers(
|
|
624
|
+
platform: string,
|
|
625
|
+
selfId: string,
|
|
626
|
+
groupId: string,
|
|
627
|
+
): Promise<ContactCacheItem[]> {
|
|
628
|
+
for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
|
|
629
|
+
try {
|
|
630
|
+
const value = await this.db.get(key)
|
|
631
|
+
const parsed = JSON.parse(value) as unknown
|
|
632
|
+
return Array.isArray(parsed) ? parsed as ContactCacheItem[] : []
|
|
633
|
+
} catch {
|
|
634
|
+
// 新键或旧键可能不存在,继续尝试另一个
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return []
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async setGroupMembers(
|
|
641
|
+
platform: string,
|
|
642
|
+
selfId: string,
|
|
643
|
+
groupId: string,
|
|
644
|
+
members: ContactCacheItem[],
|
|
645
|
+
) {
|
|
646
|
+
await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members))
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async getGroupMember(
|
|
650
|
+
platform: string,
|
|
651
|
+
selfId: string,
|
|
652
|
+
groupId: string,
|
|
653
|
+
userId: string,
|
|
654
|
+
): Promise<ContactCacheItem | null> {
|
|
655
|
+
const members = await this.getGroupMembers(platform, selfId, groupId)
|
|
656
|
+
return members.find((item) => item.id === userId) ?? null
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async appendGroupMember(
|
|
660
|
+
platform: string,
|
|
661
|
+
selfId: string,
|
|
662
|
+
groupId: string,
|
|
663
|
+
member: ContactCacheItem,
|
|
664
|
+
) {
|
|
665
|
+
const members = await this.getGroupMembers(platform, selfId, groupId)
|
|
666
|
+
const next = members.filter((item) => item.id !== member.id)
|
|
667
|
+
next.push(member)
|
|
668
|
+
await this.setGroupMembers(platform, selfId, groupId, next)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async getAllContacts(): Promise<Array<{
|
|
672
|
+
platform: string
|
|
673
|
+
selfId: string
|
|
674
|
+
type: string
|
|
675
|
+
contacts: ContactCacheItem[]
|
|
676
|
+
}>> {
|
|
677
|
+
type ContactEntry = {
|
|
678
|
+
platform: string
|
|
679
|
+
selfId: string
|
|
680
|
+
type: string
|
|
681
|
+
contacts: ContactCacheItem[]
|
|
682
|
+
}
|
|
683
|
+
const result: Array<{
|
|
684
|
+
platform: string
|
|
685
|
+
selfId: string
|
|
686
|
+
type: string
|
|
687
|
+
contacts: ContactCacheItem[]
|
|
688
|
+
}> = []
|
|
689
|
+
const byTriple = new Map<string, {
|
|
690
|
+
kind: 'new' | 'legacy'
|
|
691
|
+
entry: ContactEntry
|
|
692
|
+
}>()
|
|
693
|
+
for await (const [key, value] of this.db.iterator<string, string>({
|
|
694
|
+
gte: 'c:',
|
|
695
|
+
lte: 'c:\uffff',
|
|
696
|
+
})) {
|
|
697
|
+
if (!key.startsWith('c:')) continue
|
|
698
|
+
let source: 'new' | 'legacy' = 'legacy'
|
|
699
|
+
let platform = ''
|
|
700
|
+
let selfId = ''
|
|
701
|
+
let type = ''
|
|
702
|
+
const parts = key.slice(2).split(':')
|
|
703
|
+
if (parts.length === 3) {
|
|
704
|
+
platform = decodeKeyPart(parts[0])
|
|
705
|
+
selfId = decodeKeyPart(parts[1])
|
|
706
|
+
type = decodeKeyPart(parts[2])
|
|
707
|
+
source = 'new'
|
|
708
|
+
} else {
|
|
709
|
+
const typeIndex = key.lastIndexOf(':')
|
|
710
|
+
if (typeIndex <= 2) continue
|
|
711
|
+
type = key.slice(typeIndex + 1)
|
|
712
|
+
const rest = key.slice(2, typeIndex)
|
|
713
|
+
const sep = rest.lastIndexOf(':')
|
|
714
|
+
if (sep <= 0) continue
|
|
715
|
+
platform = rest.slice(0, sep)
|
|
716
|
+
selfId = rest.slice(sep + 1)
|
|
717
|
+
}
|
|
718
|
+
if (!platform || !selfId || !type) continue
|
|
719
|
+
try {
|
|
720
|
+
const parsed = JSON.parse(value) as unknown
|
|
721
|
+
const entry: ContactEntry = {
|
|
722
|
+
platform,
|
|
723
|
+
selfId,
|
|
724
|
+
type,
|
|
725
|
+
contacts: Array.isArray(parsed) ? parsed as ContactCacheItem[] : [],
|
|
726
|
+
}
|
|
727
|
+
if (entry.type === 'group') {
|
|
728
|
+
entry.contacts = entry.contacts.filter(isUsableGroupContact)
|
|
729
|
+
}
|
|
730
|
+
const triple = JSON.stringify([platform, selfId, type])
|
|
731
|
+
const existing = byTriple.get(triple)
|
|
732
|
+
if (!existing || (existing.kind === 'legacy' && source === 'new')) {
|
|
733
|
+
byTriple.set(triple, { kind: source, entry })
|
|
734
|
+
}
|
|
735
|
+
} catch {
|
|
736
|
+
this.logger.warn('联系人缓存解析失败:', key)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
for (const { entry } of byTriple.values()) {
|
|
740
|
+
result.push(entry)
|
|
741
|
+
}
|
|
742
|
+
return result
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async trimMessages(platform: string, selfId: string, channelId: string) {
|
|
746
|
+
let count = 0
|
|
747
|
+
const toDelete: string[] = []
|
|
748
|
+
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
749
|
+
for await (const [key] of this.db.iterator<string, string>({
|
|
750
|
+
gte: prefix,
|
|
751
|
+
lte: `${prefix}\uffff`,
|
|
752
|
+
})) {
|
|
753
|
+
count += 1
|
|
754
|
+
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (!toDelete.length) return
|
|
758
|
+
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
759
|
+
this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private async trimSelfMessages(platform: string, selfId: string, channelId: string) {
|
|
763
|
+
let count = 0
|
|
764
|
+
const toDelete: string[] = []
|
|
765
|
+
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
766
|
+
for await (const [key] of this.db.iterator<string, string>({
|
|
767
|
+
gte: prefix,
|
|
768
|
+
lte: `${prefix}\uffff`,
|
|
769
|
+
})) {
|
|
770
|
+
count += 1
|
|
771
|
+
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
772
|
+
}
|
|
773
|
+
if (!toDelete.length) return
|
|
774
|
+
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
775
|
+
this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
|
|
776
|
+
}
|
|
777
|
+
}
|