koishi-plugin-chat-patch 5.6.1 → 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/src/database.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { Level } from 'level'
2
- import { Context } from 'koishi'
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
- const time = String(record.timestamp).padStart(16, '0')
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 CompactableLevel = Level<string, string> & {
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 SharedDatabase {
81
- dir: string
82
- db: Level<string, string>
83
- refs: number
84
- opened: boolean
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
- const sharedDatabases = new Map<string, SharedDatabase>()
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
- // 同目录复用同一个 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)
154
+ get isOpen(): boolean {
155
+ return this.opened
97
156
  }
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)
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 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
163
+ async close() {
164
+ if (!this.opened) return
165
+ await this.level.close()
166
+ this.opened = false
117
167
  }
118
- if (!shared.opened) {
119
- if (sharedDatabases.get(shared.dir) === shared) sharedDatabases.delete(shared.dir)
120
- return true
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
- export class ChatDatabase {
132
- private readonly dir: string
133
- private shared?: SharedDatabase
173
+ async get(key: string): Promise<string> {
174
+ return await this.level.get(key)
175
+ }
134
176
 
135
- constructor(
136
- private ctx: Context,
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
- private get db(): Level<string, string> {
144
- if (!this.shared) throw new Error('LevelDB is not initialized')
145
- return this.shared.db
181
+ async batch(operations: KvWriteOperation[]) {
182
+ await this.level.batch(operations)
146
183
  }
147
184
 
148
- async initialize() {
149
- this.shared = await acquireSharedDatabase(this.dir)
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 dispose() {
160
- const closed = await this.releaseShared()
161
- if (closed) this.logger.logInfo('LevelDB closed')
189
+ async vacuum() {
190
+ await this.level.compactRange('', '\uffff')
162
191
  }
163
192
 
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
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
- const opening = shared.db.open().then(() => {
172
- shared.opened = true
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
- shared.opening = opening
175
- try {
176
- await opening
177
- } finally {
178
- shared.opening = undefined
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
- private async releaseShared(): Promise<boolean> {
183
- const shared = this.shared
184
- this.shared = undefined
185
- if (shared) return releaseSharedDatabase(shared)
186
- return false
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
- // 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
304
+ // 先清空记录,再强制压缩,让旧页也能被回收
191
305
  await this.db.clear()
192
- const db = this.db as unknown as CompactableLevel
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.trimMessages(record.platform, record.selfId, record.channelId || '')
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.trimSelfMessages(record.platform, record.selfId, record.channelId)
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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<string, string>({
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
- 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>({
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
- count += 1
754
- if (count > this.config.maxMessagesPerChannel) toDelete.push(key)
871
+ rows.push({ key, time: this.extractRecordTime(value) })
755
872
  }
756
873
  }
757
- if (!toDelete.length) return
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
- 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)
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 || this.isBlocked(platform)) continue
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