koishi-plugin-chat-patch 5.6.1 → 6.1.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/web/dist/assets/Chat-BLcuHxzh.js +28 -0
- package/client/web/dist/assets/{Chat-BXVWj5Bd.css → Chat-Cee07eDL.css} +1 -1
- package/client/web/dist/assets/{MsgBody-CzrxyiFO.js → MsgBody-krZlLzRL.js} +1 -1
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CLmUjKws.js +67 -0
- package/client/web/dist/assets/{index-EJ2CcODr.js → index-DOJoilEy.js} +57 -60
- package/client/web/dist/index.html +1 -1
- package/client/web/src/assets/l10n/zh-CN.po +0 -3
- package/client/web/src/components/MsgBody.vue +29 -20
- package/client/web/src/function/connect.ts +17 -18
- package/client/web/src/function/msg.ts +2 -0
- package/client/web/src/function/satori-model.ts +2 -0
- package/client/web/src/function/satori.ts +18 -12
- package/client/web/src/function/utils/appUtil.ts +27 -6
- package/client/web/src/function/utils/msgUtil.ts +27 -21
- package/client/web/src/pages/Chat.vue +35 -32
- package/lib/database.d.ts +5 -3
- package/lib/gateway.d.ts +2 -4
- package/lib/index.d.ts +17 -0
- package/lib/index.js +5409 -473
- package/lib/media.d.ts +1 -1
- package/lib/recorder.d.ts +1 -4
- package/lib/self-message.d.ts +1 -4
- package/lib/types.d.ts +4 -0
- package/package.json +25 -10
- package/src/bootstrap.ts +3 -2
- package/src/config.ts +1 -5
- package/src/database.ts +313 -139
- package/src/gateway.ts +21 -13
- package/src/index.ts +59 -54
- package/src/media.ts +43 -12
- package/src/recorder.ts +2 -12
- package/src/self-message.ts +7 -14
- package/src/server.d.ts +17 -3
- package/src/types.ts +4 -0
- package/src/web.ts +245 -151
- package/client/web/dist/assets/Chat-m4-U5uHa.js +0 -28
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +0 -67
- package/lib/bootstrap.d.ts +0 -6
- package/lib/satori.d.ts +0 -3
- package/lib/web.d.ts +0 -7
package/src/database.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import path from 'node:path'
|
|
1
|
+
import { Context } from 'koishi'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
4
|
import { createHash } from 'node:crypto'
|
|
5
5
|
|
|
6
6
|
import { Config } from './config'
|
|
7
7
|
import { ContactCacheItem, MessageRecord, PinnedState, SelfMessageRecord } from './types'
|
|
8
8
|
import { PluginLogger } from './logger'
|
|
9
9
|
|
|
10
|
-
function encodeKeyPart(value: string): string {
|
|
11
|
-
return encodeURIComponent(value)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function
|
|
10
|
+
function encodeKeyPart(value: string): string {
|
|
11
|
+
return encodeURIComponent(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeTimestampMs(value: unknown): number {
|
|
15
|
+
const num = Number(value ?? 0)
|
|
16
|
+
if (!Number.isFinite(num) || num <= 0) return 0
|
|
17
|
+
return num > 1e12 ? num : num * 1000
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function decodeKeyPart(value: string): string {
|
|
15
21
|
try {
|
|
16
22
|
return decodeURIComponent(value)
|
|
17
23
|
} catch {
|
|
@@ -27,8 +33,11 @@ function legacyMessagePrefix(platform: string, selfId: string, channelId: string
|
|
|
27
33
|
return `m:${platform}:${selfId}:${channelId}:`
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
function messageKey(record: MessageRecord): string {
|
|
31
|
-
|
|
36
|
+
function messageKey(record: MessageRecord): string {
|
|
37
|
+
// 统一用毫秒排序,前端 beforeTime 也传毫秒,避免按秒存储时分页取到同一批消息
|
|
38
|
+
const time = String(
|
|
39
|
+
normalizeTimestampMs(record.timestampMs ?? record.timestamp ?? record.receivedAt),
|
|
40
|
+
).padStart(16, '0')
|
|
32
41
|
return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -37,8 +46,10 @@ function selfMessagePrefix(platform: string, selfId: string, channelId: string):
|
|
|
37
46
|
return `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:${encodeKeyPart(channelId)}:`
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
function selfMessageKey(record: SelfMessageRecord): string {
|
|
41
|
-
const time = String(
|
|
49
|
+
function selfMessageKey(record: SelfMessageRecord): string {
|
|
50
|
+
const time = String(
|
|
51
|
+
normalizeTimestampMs(record.timestampMs ?? record.timestamp ?? record.sentAt),
|
|
52
|
+
).padStart(16, '0')
|
|
42
53
|
return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart(record.id || 'unknown')}`
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -73,130 +84,240 @@ function isUsableGroupContact(item: ContactCacheItem): boolean {
|
|
|
73
84
|
return true
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
type
|
|
87
|
+
type KvWriteOperation =
|
|
88
|
+
| { type: 'put'; key: string; value: string }
|
|
89
|
+
| { type: 'del'; key: string }
|
|
90
|
+
|
|
91
|
+
interface KvIteratorOptions {
|
|
92
|
+
gte?: string
|
|
93
|
+
lte?: string
|
|
94
|
+
lt?: string
|
|
95
|
+
reverse?: boolean
|
|
96
|
+
limit?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface LevelIteratorOptions {
|
|
100
|
+
gte?: string
|
|
101
|
+
lte?: string
|
|
102
|
+
lt?: string
|
|
103
|
+
reverse?: boolean
|
|
104
|
+
limit?: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface LevelDatabase {
|
|
108
|
+
open(): Promise<void>
|
|
109
|
+
close(): Promise<void>
|
|
110
|
+
put(key: string, value: string): Promise<void>
|
|
111
|
+
get(key: string): Promise<string>
|
|
112
|
+
del(key: string): Promise<void>
|
|
113
|
+
batch(operations: KvWriteOperation[]): Promise<void>
|
|
114
|
+
clear(): Promise<void>
|
|
115
|
+
iterator<K, V>(options?: LevelIteratorOptions): AsyncIterable<[K, V]>
|
|
77
116
|
compactRange(start: string, end: string): Promise<void>
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
interface
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
interface LevelConstructor {
|
|
120
|
+
new (
|
|
121
|
+
location: string,
|
|
122
|
+
options?: { keyEncoding?: string; valueEncoding?: string },
|
|
123
|
+
): LevelDatabase
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface LevelModule {
|
|
127
|
+
Level: LevelConstructor
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface WNodeService {
|
|
131
|
+
import<T>(packageName: string, options?: {
|
|
132
|
+
allowInstall?: boolean
|
|
133
|
+
useRequire?: boolean
|
|
134
|
+
version?: string
|
|
135
|
+
}): Promise<T>
|
|
87
136
|
}
|
|
137
|
+
|
|
138
|
+
interface KvStore {
|
|
139
|
+
readonly path: string
|
|
140
|
+
readonly driver: 'level'
|
|
141
|
+
readonly isOpen: boolean
|
|
142
|
+
put(key: string, value: string): Promise<void>
|
|
143
|
+
get(key: string): Promise<string>
|
|
144
|
+
del(key: string): Promise<void>
|
|
145
|
+
batch(operations: KvWriteOperation[]): Promise<void>
|
|
146
|
+
clear(): Promise<void>
|
|
147
|
+
vacuum(): Promise<void>
|
|
148
|
+
iterator(options?: KvIteratorOptions): AsyncGenerator<[string, string]>
|
|
149
|
+
close(): Promise<void> | void
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// LevelDB 的读写性能适合这种消息缓存,恢复为之前的存储后端
|
|
153
|
+
class LevelKV implements KvStore {
|
|
154
|
+
readonly driver = 'level' as const
|
|
155
|
+
private opened = false
|
|
88
156
|
|
|
89
|
-
|
|
157
|
+
constructor(
|
|
158
|
+
readonly path: string,
|
|
159
|
+
private readonly level: LevelDatabase,
|
|
160
|
+
) {}
|
|
90
161
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let shared = sharedDatabases.get(dir)
|
|
94
|
-
if (shared?.closing) {
|
|
95
|
-
await shared.closing.catch(() => undefined)
|
|
96
|
-
shared = sharedDatabases.get(dir)
|
|
162
|
+
get isOpen(): boolean {
|
|
163
|
+
return this.opened
|
|
97
164
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
refs: 0,
|
|
103
|
-
opened: false,
|
|
104
|
-
}
|
|
105
|
-
sharedDatabases.set(dir, shared)
|
|
165
|
+
|
|
166
|
+
async open() {
|
|
167
|
+
await this.level.open()
|
|
168
|
+
this.opened = true
|
|
106
169
|
}
|
|
107
|
-
shared.refs += 1
|
|
108
|
-
return shared
|
|
109
|
-
}
|
|
110
170
|
|
|
111
|
-
async
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await shared.closing
|
|
116
|
-
return true
|
|
171
|
+
async close() {
|
|
172
|
+
if (!this.opened) return
|
|
173
|
+
await this.level.close()
|
|
174
|
+
this.opened = false
|
|
117
175
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
176
|
+
|
|
177
|
+
async put(key: string, value: string) {
|
|
178
|
+
await this.level.put(key, value)
|
|
121
179
|
}
|
|
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
|
|
129
|
-
}
|
|
130
180
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
async get(key: string): Promise<string> {
|
|
182
|
+
return await this.level.get(key)
|
|
183
|
+
}
|
|
134
184
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
private config: Config,
|
|
138
|
-
private logger: PluginLogger,
|
|
139
|
-
) {
|
|
140
|
-
this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
|
|
185
|
+
async del(key: string) {
|
|
186
|
+
await this.level.del(key)
|
|
141
187
|
}
|
|
142
188
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return this.shared.db
|
|
189
|
+
async batch(operations: KvWriteOperation[]) {
|
|
190
|
+
await this.level.batch(operations)
|
|
146
191
|
}
|
|
147
192
|
|
|
148
|
-
async
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
await this.ensureOpen()
|
|
152
|
-
this.logger.logInfo('LevelDB 已打开:', this.shared.db.location)
|
|
153
|
-
} catch (error) {
|
|
154
|
-
await this.releaseShared()
|
|
155
|
-
throw error
|
|
156
|
-
}
|
|
193
|
+
async clear() {
|
|
194
|
+
await this.level.clear()
|
|
157
195
|
}
|
|
158
196
|
|
|
159
|
-
async
|
|
160
|
-
|
|
161
|
-
if (closed) this.logger.logInfo('LevelDB closed')
|
|
197
|
+
async vacuum() {
|
|
198
|
+
await this.level.compactRange('', '\uffff')
|
|
162
199
|
}
|
|
163
200
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
if (shared.opening) {
|
|
168
|
-
await shared.opening
|
|
169
|
-
return
|
|
201
|
+
async *iterator(options: KvIteratorOptions = {}): AsyncGenerator<[string, string]> {
|
|
202
|
+
for await (const [key, value] of this.level.iterator<string, string>(options)) {
|
|
203
|
+
yield [key, value]
|
|
170
204
|
}
|
|
171
|
-
|
|
172
|
-
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface SharedDatabase {
|
|
209
|
+
path: string
|
|
210
|
+
db: KvStore
|
|
211
|
+
refs: number
|
|
212
|
+
closing?: Promise<void>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sharedDatabases = new Map<string, SharedDatabase>()
|
|
216
|
+
|
|
217
|
+
// 同文件复用同一个 LevelDB 连接,避免 HMR 卸载/重载期间互相持有旧句柄
|
|
218
|
+
async function acquireSharedDatabase(filePath: string, Level: LevelConstructor): Promise<SharedDatabase> {
|
|
219
|
+
let shared = sharedDatabases.get(filePath)
|
|
220
|
+
if (shared?.closing) {
|
|
221
|
+
await shared.closing.catch(() => undefined)
|
|
222
|
+
shared = sharedDatabases.get(filePath)
|
|
223
|
+
}
|
|
224
|
+
if (!shared) {
|
|
225
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
226
|
+
const level = new Level(filePath, {
|
|
227
|
+
keyEncoding: 'utf8',
|
|
228
|
+
valueEncoding: 'utf8',
|
|
173
229
|
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
230
|
+
const db = new LevelKV(filePath, level)
|
|
231
|
+
await db.open()
|
|
232
|
+
shared = {
|
|
233
|
+
path: filePath,
|
|
234
|
+
db,
|
|
235
|
+
refs: 0,
|
|
236
|
+
}
|
|
237
|
+
sharedDatabases.set(filePath, shared)
|
|
238
|
+
}
|
|
239
|
+
shared.refs += 1
|
|
240
|
+
return shared
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function releaseSharedDatabase(shared: SharedDatabase): Promise<boolean> {
|
|
244
|
+
shared.refs -= 1
|
|
245
|
+
if (shared.refs > 0) return false
|
|
246
|
+
if (shared.closing) {
|
|
247
|
+
await shared.closing
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
const closing = Promise.resolve().then(() => {
|
|
251
|
+
if (shared.db.isOpen) return shared.db.close()
|
|
252
|
+
}).catch(() => undefined).finally(() => {
|
|
253
|
+
if (sharedDatabases.get(shared.path) === shared) sharedDatabases.delete(shared.path)
|
|
254
|
+
})
|
|
255
|
+
shared.closing = closing
|
|
256
|
+
await closing
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class ChatDatabase {
|
|
261
|
+
private readonly dir: string
|
|
262
|
+
private shared?: SharedDatabase
|
|
263
|
+
|
|
264
|
+
constructor(
|
|
265
|
+
private ctx: Context,
|
|
266
|
+
private config: Config,
|
|
267
|
+
private logger: PluginLogger,
|
|
268
|
+
) {
|
|
269
|
+
this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private get db(): KvStore {
|
|
273
|
+
if (!this.shared) throw new Error('Store is not initialized')
|
|
274
|
+
return this.shared.db
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async initialize() {
|
|
278
|
+
const levelModule = await this.loadLevelModule()
|
|
279
|
+
this.shared = await acquireSharedDatabase(this.dir, levelModule.Level)
|
|
280
|
+
this.logger.logInfo('LevelDB 已打开:', this.shared.db.driver, this.shared.path)
|
|
180
281
|
}
|
|
181
282
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
283
|
+
// 通过 w-node 服务动态安装并加载 level,原生 .node 不会进入插件依赖目录
|
|
284
|
+
private async loadLevelModule(): Promise<LevelModule> {
|
|
285
|
+
const nodeService = this.ctx.node
|
|
286
|
+
if (!nodeService) {
|
|
287
|
+
throw new Error('未检测到 w-node 服务,请先安装 koishi-plugin-w-node')
|
|
288
|
+
}
|
|
289
|
+
const levelModule = await nodeService.import<LevelModule>('level', {
|
|
290
|
+
version: '^10.0.0',
|
|
291
|
+
useRequire: true,
|
|
292
|
+
})
|
|
293
|
+
if (!levelModule?.Level) {
|
|
294
|
+
throw new Error('w-node 动态加载 level 失败')
|
|
295
|
+
}
|
|
296
|
+
return levelModule
|
|
187
297
|
}
|
|
188
298
|
|
|
299
|
+
async dispose() {
|
|
300
|
+
const closed = await this.releaseShared()
|
|
301
|
+
if (closed) this.logger.logInfo('存储已关闭')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async releaseShared(): Promise<boolean> {
|
|
305
|
+
const shared = this.shared
|
|
306
|
+
this.shared = undefined
|
|
307
|
+
if (shared) return releaseSharedDatabase(shared)
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
|
|
189
311
|
async clearAll() {
|
|
190
|
-
//
|
|
312
|
+
// 先清空记录,再强制压缩,让旧页也能被回收
|
|
191
313
|
await this.db.clear()
|
|
192
|
-
|
|
193
|
-
await db.compactRange('', '\uffff')
|
|
314
|
+
await this.db.vacuum()
|
|
194
315
|
this.logger.logInfo('数据库缓存已全部清空并完成压缩')
|
|
195
316
|
}
|
|
196
317
|
|
|
197
318
|
async appendMessage(record: MessageRecord) {
|
|
198
319
|
await this.db.put(messageKey(record), JSON.stringify(record))
|
|
199
|
-
await this.
|
|
320
|
+
await this.trimChannel(record.platform, record.selfId, record.channelId || '')
|
|
200
321
|
}
|
|
201
322
|
|
|
202
323
|
async upsertSelfMessage(record: SelfMessageRecord) {
|
|
@@ -210,7 +331,7 @@ export class ChatDatabase {
|
|
|
210
331
|
)
|
|
211
332
|
}
|
|
212
333
|
await this.db.put(selfMessageKey(record), JSON.stringify(record))
|
|
213
|
-
await this.
|
|
334
|
+
await this.trimChannel(record.platform, record.selfId, record.channelId)
|
|
214
335
|
}
|
|
215
336
|
|
|
216
337
|
async listSelfMessages(
|
|
@@ -221,7 +342,7 @@ export class ChatDatabase {
|
|
|
221
342
|
): Promise<SelfMessageRecord[]> {
|
|
222
343
|
const result: SelfMessageRecord[] = []
|
|
223
344
|
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
224
|
-
for await (const [, value] of this.db.iterator
|
|
345
|
+
for await (const [, value] of this.db.iterator({
|
|
225
346
|
gte: prefix,
|
|
226
347
|
lte: `${prefix}\uffff`,
|
|
227
348
|
reverse: true,
|
|
@@ -243,7 +364,7 @@ export class ChatDatabase {
|
|
|
243
364
|
const result: SelfMessageRecord[] = []
|
|
244
365
|
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
245
366
|
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
246
|
-
for await (const [, value] of this.db.iterator
|
|
367
|
+
for await (const [, value] of this.db.iterator({
|
|
247
368
|
gte: prefix,
|
|
248
369
|
lt: before,
|
|
249
370
|
reverse: true,
|
|
@@ -263,7 +384,7 @@ export class ChatDatabase {
|
|
|
263
384
|
patch: Partial<SelfMessageRecord>,
|
|
264
385
|
): Promise<boolean> {
|
|
265
386
|
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
266
|
-
for await (const [key, value] of this.db.iterator
|
|
387
|
+
for await (const [key, value] of this.db.iterator({
|
|
267
388
|
gte: prefix,
|
|
268
389
|
lte: `${prefix}\uffff`,
|
|
269
390
|
})) {
|
|
@@ -290,7 +411,7 @@ export class ChatDatabase {
|
|
|
290
411
|
): Promise<boolean> {
|
|
291
412
|
const operations: Array<{ type: 'put'; key: string; value: string }> = []
|
|
292
413
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
293
|
-
for await (const [key, value] of this.db.iterator
|
|
414
|
+
for await (const [key, value] of this.db.iterator({
|
|
294
415
|
gte: prefix,
|
|
295
416
|
lte: `${prefix}\uffff`,
|
|
296
417
|
})) {
|
|
@@ -330,7 +451,7 @@ export class ChatDatabase {
|
|
|
330
451
|
? [selfMessagePrefix(platform, selfId, channelId)]
|
|
331
452
|
: [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
|
|
332
453
|
for (const prefix of prefixes) {
|
|
333
|
-
for await (const [, value] of this.db.iterator
|
|
454
|
+
for await (const [, value] of this.db.iterator({
|
|
334
455
|
gte: prefix,
|
|
335
456
|
lte: `${prefix}\uffff`,
|
|
336
457
|
})) {
|
|
@@ -355,7 +476,7 @@ export class ChatDatabase {
|
|
|
355
476
|
) {
|
|
356
477
|
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
357
478
|
const toDelete: string[] = []
|
|
358
|
-
for await (const [key, value] of this.db.iterator
|
|
479
|
+
for await (const [key, value] of this.db.iterator({
|
|
359
480
|
gte: prefix,
|
|
360
481
|
lte: `${prefix}\uffff`,
|
|
361
482
|
})) {
|
|
@@ -396,7 +517,7 @@ export class ChatDatabase {
|
|
|
396
517
|
): Promise<MessageRecord[]> {
|
|
397
518
|
const result: MessageRecord[] = []
|
|
398
519
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
399
|
-
for await (const [, value] of this.db.iterator
|
|
520
|
+
for await (const [, value] of this.db.iterator({
|
|
400
521
|
gte: prefix,
|
|
401
522
|
lte: `${prefix}\uffff`,
|
|
402
523
|
reverse: true,
|
|
@@ -426,7 +547,7 @@ export class ChatDatabase {
|
|
|
426
547
|
const result: MessageRecord[] = []
|
|
427
548
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
428
549
|
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
429
|
-
for await (const [, value] of this.db.iterator
|
|
550
|
+
for await (const [, value] of this.db.iterator({
|
|
430
551
|
gte: prefix,
|
|
431
552
|
lt: before,
|
|
432
553
|
reverse: true,
|
|
@@ -449,7 +570,7 @@ export class ChatDatabase {
|
|
|
449
570
|
async clearChannel(platform: string, selfId: string, channelId: string) {
|
|
450
571
|
const operations: Array<{ type: 'del'; key: string }> = []
|
|
451
572
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
452
|
-
for await (const [key] of this.db.iterator
|
|
573
|
+
for await (const [key] of this.db.iterator({
|
|
453
574
|
gte: prefix,
|
|
454
575
|
lte: `${prefix}\uffff`,
|
|
455
576
|
})) {
|
|
@@ -457,7 +578,7 @@ export class ChatDatabase {
|
|
|
457
578
|
}
|
|
458
579
|
}
|
|
459
580
|
const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
|
|
460
|
-
for await (const [key] of this.db.iterator
|
|
581
|
+
for await (const [key] of this.db.iterator({
|
|
461
582
|
gte: selfPrefix,
|
|
462
583
|
lte: `${selfPrefix}\uffff`,
|
|
463
584
|
})) {
|
|
@@ -521,7 +642,7 @@ export class ChatDatabase {
|
|
|
521
642
|
|
|
522
643
|
async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
|
|
523
644
|
const result: Array<{ filePath: string; channelId: string }> = []
|
|
524
|
-
for await (const [, value] of this.db.iterator
|
|
645
|
+
for await (const [, value] of this.db.iterator({
|
|
525
646
|
gte: 'media:',
|
|
526
647
|
lte: 'media:\uffff',
|
|
527
648
|
})) {
|
|
@@ -548,7 +669,7 @@ export class ChatDatabase {
|
|
|
548
669
|
async removeMediaByPath(filePath: string) {
|
|
549
670
|
const normalized = path.normalize(filePath)
|
|
550
671
|
const toDelete: string[] = []
|
|
551
|
-
for await (const [key, value] of this.db.iterator
|
|
672
|
+
for await (const [key, value] of this.db.iterator({
|
|
552
673
|
gte: 'media:',
|
|
553
674
|
lte: 'media:\uffff',
|
|
554
675
|
})) {
|
|
@@ -690,7 +811,7 @@ export class ChatDatabase {
|
|
|
690
811
|
kind: 'new' | 'legacy'
|
|
691
812
|
entry: ContactEntry
|
|
692
813
|
}>()
|
|
693
|
-
for await (const [key, value] of this.db.iterator
|
|
814
|
+
for await (const [key, value] of this.db.iterator({
|
|
694
815
|
gte: 'c:',
|
|
695
816
|
lte: 'c:\uffff',
|
|
696
817
|
})) {
|
|
@@ -742,36 +863,89 @@ export class ChatDatabase {
|
|
|
742
863
|
return result
|
|
743
864
|
}
|
|
744
865
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
866
|
+
// 收到消息或机器人发送消息后统一裁剪,m 和 sm 合起来按时间保留上限
|
|
867
|
+
private async trimChannel(platform: string, selfId: string, channelId: string) {
|
|
868
|
+
const rows: Array<{ key: string; time: number }> = []
|
|
869
|
+
const prefixes = [
|
|
870
|
+
messagePrefix(platform, selfId, channelId),
|
|
871
|
+
legacyMessagePrefix(platform, selfId, channelId),
|
|
872
|
+
selfMessagePrefix(platform, selfId, channelId),
|
|
873
|
+
]
|
|
874
|
+
for (const prefix of prefixes) {
|
|
875
|
+
for await (const [key, value] of this.db.iterator({
|
|
750
876
|
gte: prefix,
|
|
751
877
|
lte: `${prefix}\uffff`,
|
|
752
878
|
})) {
|
|
753
|
-
|
|
754
|
-
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
879
|
+
rows.push({ key, time: this.extractRecordTime(value) })
|
|
755
880
|
}
|
|
756
881
|
}
|
|
757
|
-
|
|
758
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
759
|
-
this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
|
|
882
|
+
await this.trimRows(rows)
|
|
760
883
|
}
|
|
761
884
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
})
|
|
770
|
-
|
|
771
|
-
|
|
885
|
+
// 启动时清理历史遗留的超量消息,避免配置项只在写入时才生效
|
|
886
|
+
async cleanupExcess() {
|
|
887
|
+
const groups = new Map<string, {
|
|
888
|
+
platform: string
|
|
889
|
+
selfId: string
|
|
890
|
+
channelId: string
|
|
891
|
+
rows: Array<{ key: string; time: number }>
|
|
892
|
+
}>()
|
|
893
|
+
const collect = async (prefix: string) => {
|
|
894
|
+
for await (const [key, value] of this.db.iterator({
|
|
895
|
+
gte: prefix,
|
|
896
|
+
lte: `${prefix}\uffff`,
|
|
897
|
+
})) {
|
|
898
|
+
let parsed: Record<string, unknown>
|
|
899
|
+
try {
|
|
900
|
+
parsed = JSON.parse(value) as Record<string, unknown>
|
|
901
|
+
} catch {
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
const platform = typeof parsed.platform === 'string' ? parsed.platform : ''
|
|
905
|
+
const selfId = typeof parsed.selfId === 'string' ? parsed.selfId : ''
|
|
906
|
+
const channelId = typeof parsed.channelId === 'string' ? parsed.channelId : ''
|
|
907
|
+
if (!platform || !selfId) continue
|
|
908
|
+
const groupKey = JSON.stringify([platform, selfId, channelId])
|
|
909
|
+
let group = groups.get(groupKey)
|
|
910
|
+
if (!group) {
|
|
911
|
+
group = { platform, selfId, channelId, rows: [] }
|
|
912
|
+
groups.set(groupKey, group)
|
|
913
|
+
}
|
|
914
|
+
group.rows.push({ key, time: this.extractRecordTime(value) })
|
|
915
|
+
}
|
|
772
916
|
}
|
|
773
|
-
|
|
774
|
-
await
|
|
775
|
-
|
|
917
|
+
await collect('m:')
|
|
918
|
+
await collect('sm:')
|
|
919
|
+
|
|
920
|
+
let removed = 0
|
|
921
|
+
for (const group of groups.values()) {
|
|
922
|
+
const excess = group.rows.length - this.config.maxMessagesPerChannel
|
|
923
|
+
if (excess <= 0) continue
|
|
924
|
+
group.rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
|
|
925
|
+
const keys = group.rows.slice(0, excess).map((item) => item.key)
|
|
926
|
+
await this.db.batch(keys.map((key) => ({ type: 'del', key })))
|
|
927
|
+
removed += keys.length
|
|
928
|
+
}
|
|
929
|
+
if (removed) {
|
|
930
|
+
this.logger.logInfo(`启动时已裁剪 ${removed} 条超出上限的历史消息`)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private async trimRows(rows: Array<{ key: string; time: number }>) {
|
|
935
|
+
const excess = rows.length - this.config.maxMessagesPerChannel
|
|
936
|
+
if (excess <= 0) return
|
|
937
|
+
rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
|
|
938
|
+
const keys = rows.slice(0, excess).map((item) => item.key)
|
|
939
|
+
await this.db.batch(keys.map((key) => ({ type: 'del', key })))
|
|
940
|
+
this.logger.logInfo(`频道历史已裁剪 ${keys.length} 条`)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private extractRecordTime(value: string): number {
|
|
944
|
+
try {
|
|
945
|
+
const parsed = JSON.parse(value) as Record<string, unknown>
|
|
946
|
+
return normalizeTimestampMs(parsed.timestampMs ?? parsed.timestamp ?? parsed.sentAt ?? parsed.receivedAt)
|
|
947
|
+
} catch {
|
|
948
|
+
return 0
|
|
949
|
+
}
|
|
776
950
|
}
|
|
777
951
|
}
|