koishi-plugin-chat-patch 5.6.0 → 6.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.
- package/client/web/dist/assets/Chat-BkRnAj_r.js +28 -0
- package/client/web/dist/assets/{Chat-BXVWj5Bd.css → Chat-D4tCqRrO.css} +1 -1
- package/client/web/dist/assets/{MsgBody-CzrxyiFO.js → MsgBody-BaoDyXUz.js} +1 -1
- package/client/web/dist/assets/{MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js → MsgBody.vue_vue_type_script_setup_true_lang-CgpEB8mj.js} +1 -1
- package/client/web/dist/assets/{index-EJ2CcODr.js → index--7-eCx0r.js} +57 -57
- package/client/web/dist/index.html +1 -1
- package/client/web/src/function/connect.ts +5 -12
- package/client/web/src/function/satori.ts +18 -12
- package/client/web/src/function/utils/appUtil.ts +27 -6
- package/client/web/src/pages/Chat.vue +32 -24
- 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 +5359 -452
- package/lib/media.d.ts +1 -1
- package/lib/recorder.d.ts +1 -4
- package/lib/self-message.d.ts +1 -4
- package/package.json +25 -10
- package/src/bootstrap.ts +3 -2
- package/src/config.ts +1 -5
- package/src/database.ts +297 -131
- package/src/gateway.ts +21 -13
- package/src/index.ts +59 -54
- package/src/media.ts +43 -12
- package/src/recorder.ts +0 -11
- package/src/satori.ts +8 -5
- package/src/self-message.ts +3 -13
- package/src/server.d.ts +24 -9
- package/src/web.ts +144 -78
- package/client/web/dist/assets/Chat-m4-U5uHa.js +0 -28
- package/lib/bootstrap.d.ts +0 -6
- package/lib/satori.d.ts +0 -3
- package/lib/types.d.ts +0 -138
- package/lib/web.d.ts +0 -7
package/src/database.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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'
|
|
@@ -28,7 +28,10 @@ function legacyMessagePrefix(platform: string, selfId: string, channelId: string
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function messageKey(record: MessageRecord): string {
|
|
31
|
-
|
|
31
|
+
// 统一用毫秒排序,前端 beforeTime 也传毫秒,避免按秒存储时分页取到同一批消息
|
|
32
|
+
const time = String(
|
|
33
|
+
record.receivedAt ?? record.timestampMs ?? Number(record.timestamp) * 1000,
|
|
34
|
+
).padStart(16, '0')
|
|
32
35
|
return `${messagePrefix(record.platform, record.selfId, record.channelId || '')}${time}:${encodeKeyPart(record.id || 'unknown')}`
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -73,130 +76,240 @@ function isUsableGroupContact(item: ContactCacheItem): boolean {
|
|
|
73
76
|
return true
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
type
|
|
79
|
+
type KvWriteOperation =
|
|
80
|
+
| { type: 'put'; key: string; value: string }
|
|
81
|
+
| { type: 'del'; key: string }
|
|
82
|
+
|
|
83
|
+
interface KvIteratorOptions {
|
|
84
|
+
gte?: string
|
|
85
|
+
lte?: string
|
|
86
|
+
lt?: string
|
|
87
|
+
reverse?: boolean
|
|
88
|
+
limit?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface LevelIteratorOptions {
|
|
92
|
+
gte?: string
|
|
93
|
+
lte?: string
|
|
94
|
+
lt?: string
|
|
95
|
+
reverse?: boolean
|
|
96
|
+
limit?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface LevelDatabase {
|
|
100
|
+
open(): Promise<void>
|
|
101
|
+
close(): Promise<void>
|
|
102
|
+
put(key: string, value: string): Promise<void>
|
|
103
|
+
get(key: string): Promise<string>
|
|
104
|
+
del(key: string): Promise<void>
|
|
105
|
+
batch(operations: KvWriteOperation[]): Promise<void>
|
|
106
|
+
clear(): Promise<void>
|
|
107
|
+
iterator<K, V>(options?: LevelIteratorOptions): AsyncIterable<[K, V]>
|
|
77
108
|
compactRange(start: string, end: string): Promise<void>
|
|
78
109
|
}
|
|
79
110
|
|
|
80
|
-
interface
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
opening?: Promise<void>
|
|
86
|
-
closing?: Promise<void>
|
|
111
|
+
interface LevelConstructor {
|
|
112
|
+
new (
|
|
113
|
+
location: string,
|
|
114
|
+
options?: { keyEncoding?: string; valueEncoding?: string },
|
|
115
|
+
): LevelDatabase
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
|
|
118
|
+
interface LevelModule {
|
|
119
|
+
Level: LevelConstructor
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface WNodeService {
|
|
123
|
+
import<T>(packageName: string, options?: {
|
|
124
|
+
allowInstall?: boolean
|
|
125
|
+
useRequire?: boolean
|
|
126
|
+
version?: string
|
|
127
|
+
}): Promise<T>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface KvStore {
|
|
131
|
+
readonly path: string
|
|
132
|
+
readonly driver: 'level'
|
|
133
|
+
readonly isOpen: boolean
|
|
134
|
+
put(key: string, value: string): Promise<void>
|
|
135
|
+
get(key: string): Promise<string>
|
|
136
|
+
del(key: string): Promise<void>
|
|
137
|
+
batch(operations: KvWriteOperation[]): Promise<void>
|
|
138
|
+
clear(): Promise<void>
|
|
139
|
+
vacuum(): Promise<void>
|
|
140
|
+
iterator(options?: KvIteratorOptions): AsyncGenerator<[string, string]>
|
|
141
|
+
close(): Promise<void> | void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// LevelDB 的读写性能适合这种消息缓存,恢复为之前的存储后端
|
|
145
|
+
class LevelKV implements KvStore {
|
|
146
|
+
readonly driver = 'level' as const
|
|
147
|
+
private opened = false
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
readonly path: string,
|
|
151
|
+
private readonly level: LevelDatabase,
|
|
152
|
+
) {}
|
|
90
153
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let shared = sharedDatabases.get(dir)
|
|
94
|
-
if (shared?.closing) {
|
|
95
|
-
await shared.closing.catch(() => undefined)
|
|
96
|
-
shared = sharedDatabases.get(dir)
|
|
154
|
+
get isOpen(): boolean {
|
|
155
|
+
return this.opened
|
|
97
156
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
refs: 0,
|
|
103
|
-
opened: false,
|
|
104
|
-
}
|
|
105
|
-
sharedDatabases.set(dir, shared)
|
|
157
|
+
|
|
158
|
+
async open() {
|
|
159
|
+
await this.level.open()
|
|
160
|
+
this.opened = true
|
|
106
161
|
}
|
|
107
|
-
shared.refs += 1
|
|
108
|
-
return shared
|
|
109
|
-
}
|
|
110
162
|
|
|
111
|
-
async
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await shared.closing
|
|
116
|
-
return true
|
|
163
|
+
async close() {
|
|
164
|
+
if (!this.opened) return
|
|
165
|
+
await this.level.close()
|
|
166
|
+
this.opened = false
|
|
117
167
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
|
|
169
|
+
async put(key: string, value: string) {
|
|
170
|
+
await this.level.put(key, value)
|
|
121
171
|
}
|
|
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
172
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
173
|
+
async get(key: string): Promise<string> {
|
|
174
|
+
return await this.level.get(key)
|
|
175
|
+
}
|
|
134
176
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
private config: Config,
|
|
138
|
-
private logger: PluginLogger,
|
|
139
|
-
) {
|
|
140
|
-
this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
|
|
177
|
+
async del(key: string) {
|
|
178
|
+
await this.level.del(key)
|
|
141
179
|
}
|
|
142
180
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return this.shared.db
|
|
181
|
+
async batch(operations: KvWriteOperation[]) {
|
|
182
|
+
await this.level.batch(operations)
|
|
146
183
|
}
|
|
147
184
|
|
|
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
|
-
}
|
|
185
|
+
async clear() {
|
|
186
|
+
await this.level.clear()
|
|
157
187
|
}
|
|
158
188
|
|
|
159
|
-
async
|
|
160
|
-
|
|
161
|
-
if (closed) this.logger.logInfo('LevelDB closed')
|
|
189
|
+
async vacuum() {
|
|
190
|
+
await this.level.compactRange('', '\uffff')
|
|
162
191
|
}
|
|
163
192
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
if (shared.opening) {
|
|
168
|
-
await shared.opening
|
|
169
|
-
return
|
|
193
|
+
async *iterator(options: KvIteratorOptions = {}): AsyncGenerator<[string, string]> {
|
|
194
|
+
for await (const [key, value] of this.level.iterator<string, string>(options)) {
|
|
195
|
+
yield [key, value]
|
|
170
196
|
}
|
|
171
|
-
|
|
172
|
-
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface SharedDatabase {
|
|
201
|
+
path: string
|
|
202
|
+
db: KvStore
|
|
203
|
+
refs: number
|
|
204
|
+
closing?: Promise<void>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sharedDatabases = new Map<string, SharedDatabase>()
|
|
208
|
+
|
|
209
|
+
// 同文件复用同一个 LevelDB 连接,避免 HMR 卸载/重载期间互相持有旧句柄
|
|
210
|
+
async function acquireSharedDatabase(filePath: string, Level: LevelConstructor): Promise<SharedDatabase> {
|
|
211
|
+
let shared = sharedDatabases.get(filePath)
|
|
212
|
+
if (shared?.closing) {
|
|
213
|
+
await shared.closing.catch(() => undefined)
|
|
214
|
+
shared = sharedDatabases.get(filePath)
|
|
215
|
+
}
|
|
216
|
+
if (!shared) {
|
|
217
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
218
|
+
const level = new Level(filePath, {
|
|
219
|
+
keyEncoding: 'utf8',
|
|
220
|
+
valueEncoding: 'utf8',
|
|
173
221
|
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
222
|
+
const db = new LevelKV(filePath, level)
|
|
223
|
+
await db.open()
|
|
224
|
+
shared = {
|
|
225
|
+
path: filePath,
|
|
226
|
+
db,
|
|
227
|
+
refs: 0,
|
|
228
|
+
}
|
|
229
|
+
sharedDatabases.set(filePath, shared)
|
|
230
|
+
}
|
|
231
|
+
shared.refs += 1
|
|
232
|
+
return shared
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function releaseSharedDatabase(shared: SharedDatabase): Promise<boolean> {
|
|
236
|
+
shared.refs -= 1
|
|
237
|
+
if (shared.refs > 0) return false
|
|
238
|
+
if (shared.closing) {
|
|
239
|
+
await shared.closing
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
const closing = Promise.resolve().then(() => {
|
|
243
|
+
if (shared.db.isOpen) return shared.db.close()
|
|
244
|
+
}).catch(() => undefined).finally(() => {
|
|
245
|
+
if (sharedDatabases.get(shared.path) === shared) sharedDatabases.delete(shared.path)
|
|
246
|
+
})
|
|
247
|
+
shared.closing = closing
|
|
248
|
+
await closing
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class ChatDatabase {
|
|
253
|
+
private readonly dir: string
|
|
254
|
+
private shared?: SharedDatabase
|
|
255
|
+
|
|
256
|
+
constructor(
|
|
257
|
+
private ctx: Context,
|
|
258
|
+
private config: Config,
|
|
259
|
+
private logger: PluginLogger,
|
|
260
|
+
) {
|
|
261
|
+
this.dir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'db')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private get db(): KvStore {
|
|
265
|
+
if (!this.shared) throw new Error('Store is not initialized')
|
|
266
|
+
return this.shared.db
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async initialize() {
|
|
270
|
+
const levelModule = await this.loadLevelModule()
|
|
271
|
+
this.shared = await acquireSharedDatabase(this.dir, levelModule.Level)
|
|
272
|
+
this.logger.logInfo('LevelDB 已打开:', this.shared.db.driver, this.shared.path)
|
|
180
273
|
}
|
|
181
274
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
275
|
+
// 通过 w-node 服务动态安装并加载 level,原生 .node 不会进入插件依赖目录
|
|
276
|
+
private async loadLevelModule(): Promise<LevelModule> {
|
|
277
|
+
const nodeService = this.ctx.node
|
|
278
|
+
if (!nodeService) {
|
|
279
|
+
throw new Error('未检测到 w-node 服务,请先安装 koishi-plugin-w-node')
|
|
280
|
+
}
|
|
281
|
+
const levelModule = await nodeService.import<LevelModule>('level', {
|
|
282
|
+
version: '^10.0.0',
|
|
283
|
+
useRequire: true,
|
|
284
|
+
})
|
|
285
|
+
if (!levelModule?.Level) {
|
|
286
|
+
throw new Error('w-node 动态加载 level 失败')
|
|
287
|
+
}
|
|
288
|
+
return levelModule
|
|
187
289
|
}
|
|
188
290
|
|
|
291
|
+
async dispose() {
|
|
292
|
+
const closed = await this.releaseShared()
|
|
293
|
+
if (closed) this.logger.logInfo('存储已关闭')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async releaseShared(): Promise<boolean> {
|
|
297
|
+
const shared = this.shared
|
|
298
|
+
this.shared = undefined
|
|
299
|
+
if (shared) return releaseSharedDatabase(shared)
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
189
303
|
async clearAll() {
|
|
190
|
-
//
|
|
304
|
+
// 先清空记录,再强制压缩,让旧页也能被回收
|
|
191
305
|
await this.db.clear()
|
|
192
|
-
|
|
193
|
-
await db.compactRange('', '\uffff')
|
|
306
|
+
await this.db.vacuum()
|
|
194
307
|
this.logger.logInfo('数据库缓存已全部清空并完成压缩')
|
|
195
308
|
}
|
|
196
309
|
|
|
197
310
|
async appendMessage(record: MessageRecord) {
|
|
198
311
|
await this.db.put(messageKey(record), JSON.stringify(record))
|
|
199
|
-
await this.
|
|
312
|
+
await this.trimChannel(record.platform, record.selfId, record.channelId || '')
|
|
200
313
|
}
|
|
201
314
|
|
|
202
315
|
async upsertSelfMessage(record: SelfMessageRecord) {
|
|
@@ -210,7 +323,7 @@ export class ChatDatabase {
|
|
|
210
323
|
)
|
|
211
324
|
}
|
|
212
325
|
await this.db.put(selfMessageKey(record), JSON.stringify(record))
|
|
213
|
-
await this.
|
|
326
|
+
await this.trimChannel(record.platform, record.selfId, record.channelId)
|
|
214
327
|
}
|
|
215
328
|
|
|
216
329
|
async listSelfMessages(
|
|
@@ -221,7 +334,7 @@ export class ChatDatabase {
|
|
|
221
334
|
): Promise<SelfMessageRecord[]> {
|
|
222
335
|
const result: SelfMessageRecord[] = []
|
|
223
336
|
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
224
|
-
for await (const [, value] of this.db.iterator
|
|
337
|
+
for await (const [, value] of this.db.iterator({
|
|
225
338
|
gte: prefix,
|
|
226
339
|
lte: `${prefix}\uffff`,
|
|
227
340
|
reverse: true,
|
|
@@ -243,7 +356,7 @@ export class ChatDatabase {
|
|
|
243
356
|
const result: SelfMessageRecord[] = []
|
|
244
357
|
const prefix = selfMessagePrefix(platform, selfId, channelId)
|
|
245
358
|
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
246
|
-
for await (const [, value] of this.db.iterator
|
|
359
|
+
for await (const [, value] of this.db.iterator({
|
|
247
360
|
gte: prefix,
|
|
248
361
|
lt: before,
|
|
249
362
|
reverse: true,
|
|
@@ -263,7 +376,7 @@ export class ChatDatabase {
|
|
|
263
376
|
patch: Partial<SelfMessageRecord>,
|
|
264
377
|
): Promise<boolean> {
|
|
265
378
|
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
266
|
-
for await (const [key, value] of this.db.iterator
|
|
379
|
+
for await (const [key, value] of this.db.iterator({
|
|
267
380
|
gte: prefix,
|
|
268
381
|
lte: `${prefix}\uffff`,
|
|
269
382
|
})) {
|
|
@@ -290,7 +403,7 @@ export class ChatDatabase {
|
|
|
290
403
|
): Promise<boolean> {
|
|
291
404
|
const operations: Array<{ type: 'put'; key: string; value: string }> = []
|
|
292
405
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
293
|
-
for await (const [key, value] of this.db.iterator
|
|
406
|
+
for await (const [key, value] of this.db.iterator({
|
|
294
407
|
gte: prefix,
|
|
295
408
|
lte: `${prefix}\uffff`,
|
|
296
409
|
})) {
|
|
@@ -330,7 +443,7 @@ export class ChatDatabase {
|
|
|
330
443
|
? [selfMessagePrefix(platform, selfId, channelId)]
|
|
331
444
|
: [`sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`]
|
|
332
445
|
for (const prefix of prefixes) {
|
|
333
|
-
for await (const [, value] of this.db.iterator
|
|
446
|
+
for await (const [, value] of this.db.iterator({
|
|
334
447
|
gte: prefix,
|
|
335
448
|
lte: `${prefix}\uffff`,
|
|
336
449
|
})) {
|
|
@@ -355,7 +468,7 @@ export class ChatDatabase {
|
|
|
355
468
|
) {
|
|
356
469
|
const prefix = `sm:${encodeKeyPart(platform)}:${encodeKeyPart(selfId)}:`
|
|
357
470
|
const toDelete: string[] = []
|
|
358
|
-
for await (const [key, value] of this.db.iterator
|
|
471
|
+
for await (const [key, value] of this.db.iterator({
|
|
359
472
|
gte: prefix,
|
|
360
473
|
lte: `${prefix}\uffff`,
|
|
361
474
|
})) {
|
|
@@ -396,7 +509,7 @@ export class ChatDatabase {
|
|
|
396
509
|
): Promise<MessageRecord[]> {
|
|
397
510
|
const result: MessageRecord[] = []
|
|
398
511
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
399
|
-
for await (const [, value] of this.db.iterator
|
|
512
|
+
for await (const [, value] of this.db.iterator({
|
|
400
513
|
gte: prefix,
|
|
401
514
|
lte: `${prefix}\uffff`,
|
|
402
515
|
reverse: true,
|
|
@@ -426,7 +539,7 @@ export class ChatDatabase {
|
|
|
426
539
|
const result: MessageRecord[] = []
|
|
427
540
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
428
541
|
const before = `${prefix}${String(beforeTime).padStart(16, '0')}`
|
|
429
|
-
for await (const [, value] of this.db.iterator
|
|
542
|
+
for await (const [, value] of this.db.iterator({
|
|
430
543
|
gte: prefix,
|
|
431
544
|
lt: before,
|
|
432
545
|
reverse: true,
|
|
@@ -449,7 +562,7 @@ export class ChatDatabase {
|
|
|
449
562
|
async clearChannel(platform: string, selfId: string, channelId: string) {
|
|
450
563
|
const operations: Array<{ type: 'del'; key: string }> = []
|
|
451
564
|
for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
|
|
452
|
-
for await (const [key] of this.db.iterator
|
|
565
|
+
for await (const [key] of this.db.iterator({
|
|
453
566
|
gte: prefix,
|
|
454
567
|
lte: `${prefix}\uffff`,
|
|
455
568
|
})) {
|
|
@@ -457,7 +570,7 @@ export class ChatDatabase {
|
|
|
457
570
|
}
|
|
458
571
|
}
|
|
459
572
|
const selfPrefix = selfMessagePrefix(platform, selfId, channelId)
|
|
460
|
-
for await (const [key] of this.db.iterator
|
|
573
|
+
for await (const [key] of this.db.iterator({
|
|
461
574
|
gte: selfPrefix,
|
|
462
575
|
lte: `${selfPrefix}\uffff`,
|
|
463
576
|
})) {
|
|
@@ -521,7 +634,7 @@ export class ChatDatabase {
|
|
|
521
634
|
|
|
522
635
|
async getAllMedia(): Promise<Array<{ filePath: string; channelId: string }>> {
|
|
523
636
|
const result: Array<{ filePath: string; channelId: string }> = []
|
|
524
|
-
for await (const [, value] of this.db.iterator
|
|
637
|
+
for await (const [, value] of this.db.iterator({
|
|
525
638
|
gte: 'media:',
|
|
526
639
|
lte: 'media:\uffff',
|
|
527
640
|
})) {
|
|
@@ -548,7 +661,7 @@ export class ChatDatabase {
|
|
|
548
661
|
async removeMediaByPath(filePath: string) {
|
|
549
662
|
const normalized = path.normalize(filePath)
|
|
550
663
|
const toDelete: string[] = []
|
|
551
|
-
for await (const [key, value] of this.db.iterator
|
|
664
|
+
for await (const [key, value] of this.db.iterator({
|
|
552
665
|
gte: 'media:',
|
|
553
666
|
lte: 'media:\uffff',
|
|
554
667
|
})) {
|
|
@@ -690,7 +803,7 @@ export class ChatDatabase {
|
|
|
690
803
|
kind: 'new' | 'legacy'
|
|
691
804
|
entry: ContactEntry
|
|
692
805
|
}>()
|
|
693
|
-
for await (const [key, value] of this.db.iterator
|
|
806
|
+
for await (const [key, value] of this.db.iterator({
|
|
694
807
|
gte: 'c:',
|
|
695
808
|
lte: 'c:\uffff',
|
|
696
809
|
})) {
|
|
@@ -742,36 +855,89 @@ export class ChatDatabase {
|
|
|
742
855
|
return result
|
|
743
856
|
}
|
|
744
857
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
858
|
+
// 收到消息或机器人发送消息后统一裁剪,m 和 sm 合起来按时间保留上限
|
|
859
|
+
private async trimChannel(platform: string, selfId: string, channelId: string) {
|
|
860
|
+
const rows: Array<{ key: string; time: number }> = []
|
|
861
|
+
const prefixes = [
|
|
862
|
+
messagePrefix(platform, selfId, channelId),
|
|
863
|
+
legacyMessagePrefix(platform, selfId, channelId),
|
|
864
|
+
selfMessagePrefix(platform, selfId, channelId),
|
|
865
|
+
]
|
|
866
|
+
for (const prefix of prefixes) {
|
|
867
|
+
for await (const [key, value] of this.db.iterator({
|
|
750
868
|
gte: prefix,
|
|
751
869
|
lte: `${prefix}\uffff`,
|
|
752
870
|
})) {
|
|
753
|
-
|
|
754
|
-
if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
|
|
871
|
+
rows.push({ key, time: this.extractRecordTime(value) })
|
|
755
872
|
}
|
|
756
873
|
}
|
|
757
|
-
|
|
758
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
759
|
-
this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`)
|
|
874
|
+
await this.trimRows(rows)
|
|
760
875
|
}
|
|
761
876
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
})
|
|
770
|
-
|
|
771
|
-
|
|
877
|
+
// 启动时清理历史遗留的超量消息,避免配置项只在写入时才生效
|
|
878
|
+
async cleanupExcess() {
|
|
879
|
+
const groups = new Map<string, {
|
|
880
|
+
platform: string
|
|
881
|
+
selfId: string
|
|
882
|
+
channelId: string
|
|
883
|
+
rows: Array<{ key: string; time: number }>
|
|
884
|
+
}>()
|
|
885
|
+
const collect = async (prefix: string) => {
|
|
886
|
+
for await (const [key, value] of this.db.iterator({
|
|
887
|
+
gte: prefix,
|
|
888
|
+
lte: `${prefix}\uffff`,
|
|
889
|
+
})) {
|
|
890
|
+
let parsed: Record<string, unknown>
|
|
891
|
+
try {
|
|
892
|
+
parsed = JSON.parse(value) as Record<string, unknown>
|
|
893
|
+
} catch {
|
|
894
|
+
continue
|
|
895
|
+
}
|
|
896
|
+
const platform = typeof parsed.platform === 'string' ? parsed.platform : ''
|
|
897
|
+
const selfId = typeof parsed.selfId === 'string' ? parsed.selfId : ''
|
|
898
|
+
const channelId = typeof parsed.channelId === 'string' ? parsed.channelId : ''
|
|
899
|
+
if (!platform || !selfId) continue
|
|
900
|
+
const groupKey = JSON.stringify([platform, selfId, channelId])
|
|
901
|
+
let group = groups.get(groupKey)
|
|
902
|
+
if (!group) {
|
|
903
|
+
group = { platform, selfId, channelId, rows: [] }
|
|
904
|
+
groups.set(groupKey, group)
|
|
905
|
+
}
|
|
906
|
+
group.rows.push({ key, time: this.extractRecordTime(value) })
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
await collect('m:')
|
|
910
|
+
await collect('sm:')
|
|
911
|
+
|
|
912
|
+
let removed = 0
|
|
913
|
+
for (const group of groups.values()) {
|
|
914
|
+
const excess = group.rows.length - this.config.maxMessagesPerChannel
|
|
915
|
+
if (excess <= 0) continue
|
|
916
|
+
group.rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
|
|
917
|
+
const keys = group.rows.slice(0, excess).map((item) => item.key)
|
|
918
|
+
await this.db.batch(keys.map((key) => ({ type: 'del', key })))
|
|
919
|
+
removed += keys.length
|
|
920
|
+
}
|
|
921
|
+
if (removed) {
|
|
922
|
+
this.logger.logInfo(`启动时已裁剪 ${removed} 条超出上限的历史消息`)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private async trimRows(rows: Array<{ key: string; time: number }>) {
|
|
927
|
+
const excess = rows.length - this.config.maxMessagesPerChannel
|
|
928
|
+
if (excess <= 0) return
|
|
929
|
+
rows.sort((a, b) => a.time - b.time || (a.key < b.key ? -1 : 1))
|
|
930
|
+
const keys = rows.slice(0, excess).map((item) => item.key)
|
|
931
|
+
await this.db.batch(keys.map((key) => ({ type: 'del', key })))
|
|
932
|
+
this.logger.logInfo(`频道历史已裁剪 ${keys.length} 条`)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private extractRecordTime(value: string): number {
|
|
936
|
+
try {
|
|
937
|
+
const parsed = JSON.parse(value) as Record<string, unknown>
|
|
938
|
+
return Number(parsed.receivedAt ?? parsed.timestampMs ?? parsed.timestamp ?? parsed.sentAt ?? 0) || 0
|
|
939
|
+
} catch {
|
|
940
|
+
return 0
|
|
772
941
|
}
|
|
773
|
-
if (!toDelete.length) return
|
|
774
|
-
await this.db.batch(toDelete.map((key) => ({ type: 'del', key })))
|
|
775
|
-
this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`)
|
|
776
942
|
}
|
|
777
943
|
}
|
package/src/gateway.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {} from '@koishijs/plugin-http'
|
|
|
4
4
|
import {} from '@koishijs/plugin-server'
|
|
5
5
|
import {} from '@satorijs/plugin-server'
|
|
6
6
|
|
|
7
|
-
import { Config } from './config'
|
|
8
7
|
import { ChatDatabase } from './database'
|
|
9
8
|
import { PluginLogger } from './logger'
|
|
10
9
|
import { Recorder } from './recorder'
|
|
@@ -45,10 +44,10 @@ export class SatoriGateway {
|
|
|
45
44
|
private sequence = 0
|
|
46
45
|
private logins: SatoriLoginInfo[] = []
|
|
47
46
|
private online = false
|
|
47
|
+
private lastPongAt = 0
|
|
48
48
|
|
|
49
49
|
constructor(
|
|
50
50
|
private ctx: Context,
|
|
51
|
-
private config: Config,
|
|
52
51
|
private database: ChatDatabase,
|
|
53
52
|
private recorder: Recorder,
|
|
54
53
|
private logger: PluginLogger,
|
|
@@ -99,6 +98,7 @@ export class SatoriGateway {
|
|
|
99
98
|
}
|
|
100
99
|
this.retry = 0
|
|
101
100
|
this.setOnline(true)
|
|
101
|
+
this.lastPongAt = Date.now()
|
|
102
102
|
socket.send(JSON.stringify({
|
|
103
103
|
op: Opcode.IDENTIFY,
|
|
104
104
|
body: {
|
|
@@ -109,6 +109,11 @@ export class SatoriGateway {
|
|
|
109
109
|
if (!this.pingDispose) {
|
|
110
110
|
this.pingDispose = this.ctx.setInterval(() => {
|
|
111
111
|
if (this.socket?.readyState === WS_OPEN) {
|
|
112
|
+
if (Date.now() - this.lastPongAt > 30000) {
|
|
113
|
+
this.logger.logInfo('Satori 心跳超时,主动重连')
|
|
114
|
+
this.socket.close()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
112
117
|
this.socket.send(JSON.stringify({ op: Opcode.PING, body: {} }))
|
|
113
118
|
}
|
|
114
119
|
}, 10000)
|
|
@@ -160,14 +165,27 @@ export class SatoriGateway {
|
|
|
160
165
|
|
|
161
166
|
if (payload.op === Opcode.READY) {
|
|
162
167
|
const body = getObject(payload.body)
|
|
168
|
+
this.lastPongAt = Date.now()
|
|
163
169
|
this.logins = this.normalizeLogins(body.logins)
|
|
164
170
|
this.onPayload({ kind: 'ready', logins: this.getLogins() })
|
|
165
171
|
return
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
if (payload.op === Opcode.PING) {
|
|
175
|
+
this.lastPongAt = Date.now()
|
|
176
|
+
if (this.socket?.readyState === WS_OPEN) {
|
|
177
|
+
this.socket.send(JSON.stringify({ op: Opcode.PONG, body: {} }))
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (payload.op === Opcode.PONG) {
|
|
182
|
+
this.lastPongAt = Date.now()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
168
185
|
if (payload.op !== Opcode.EVENT) return
|
|
169
186
|
|
|
170
187
|
const body = getObject(payload.body)
|
|
188
|
+
this.lastPongAt = Date.now()
|
|
171
189
|
const sn = getNumber(body.sn)
|
|
172
190
|
if (sn) {
|
|
173
191
|
this.sequence = sn
|
|
@@ -212,8 +230,6 @@ export class SatoriGateway {
|
|
|
212
230
|
sn,
|
|
213
231
|
body,
|
|
214
232
|
}
|
|
215
|
-
if (this.isBlocked(platform)) return
|
|
216
|
-
|
|
217
233
|
try {
|
|
218
234
|
await this.recorder.handleEvent(body)
|
|
219
235
|
} catch (error) {
|
|
@@ -230,7 +246,7 @@ export class SatoriGateway {
|
|
|
230
246
|
const user = getObject(login.user)
|
|
231
247
|
const platform = getString(login.platform) || getString(user.platform)
|
|
232
248
|
const selfId = getString(login.self_id) || getString(login.selfId) || getString(user.id)
|
|
233
|
-
if (!platform || !selfId
|
|
249
|
+
if (!platform || !selfId) continue
|
|
234
250
|
result.push({
|
|
235
251
|
platform,
|
|
236
252
|
selfId,
|
|
@@ -243,14 +259,6 @@ export class SatoriGateway {
|
|
|
243
259
|
return result
|
|
244
260
|
}
|
|
245
261
|
|
|
246
|
-
private isBlocked(platform: string): boolean {
|
|
247
|
-
return (this.config.blockedPlatforms ?? []).some((item) => {
|
|
248
|
-
return item.exactMatch
|
|
249
|
-
? platform === item.platformName
|
|
250
|
-
: platform.includes(item.platformName)
|
|
251
|
-
})
|
|
252
|
-
}
|
|
253
|
-
|
|
254
262
|
private setOnline(online: boolean) {
|
|
255
263
|
if (this.online === online) return
|
|
256
264
|
this.online = online
|