@modesto_ma/dsh-channels 0.3.0 → 0.4.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/service.ts CHANGED
@@ -56,8 +56,8 @@ import {
56
56
  import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
57
57
  import type {
58
58
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
59
- ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
60
- RouteRecord,
59
+ ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, PendingAttachmentRecord,
60
+ PendingAttachmentsTable, ProcessedRecord, RawDomainHandle, RouteRecord,
61
61
  } from './domain.ts'
62
62
  import { RouteStore } from './session/routes.ts'
63
63
  import { InflightRepair } from './session/repair.ts'
@@ -65,6 +65,17 @@ import { SessionManager } from './session/manager.ts'
65
65
  import type { LiveAgent, RenewCommit } from './session/manager.ts'
66
66
  import { CardDelivery } from './feishu/delivery.ts'
67
67
  import { AttachmentIntake } from './feishu/inbound-attachments.ts'
68
+ import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
69
+ import { PendingAttachmentStore } from './feishu/pending-attachments.ts'
70
+ import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
71
+ import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
72
+ import {
73
+ MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
74
+ buildQuoteContextBlock, clampInt, knownMessageOfItems, knownMessagesOfHistory, quoteItemsOf,
75
+ readQuoteItems,
76
+ } from './feishu/message-read.ts'
77
+ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
78
+ import type { AttachmentStore } from './host/attachments.ts'
68
79
  import { InboundRouter } from './routing/inbound.ts'
69
80
  import type { RequestPolicyController } from './session/request-policy.ts'
70
81
  import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
@@ -86,7 +97,8 @@ import type { FeishuConfig } from './config.ts'
86
97
  import type {
87
98
  AccessMutationResponse, AccessRequestWire, AccessWire,
88
99
  } from './settings/contract.ts'
89
- import type { OverlayStore } from './settings/overlay.ts'
100
+ import { mergeOverlay } from './settings/overlay.ts'
101
+ import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
90
102
  import { legacyTopicWorkspaceRoot } from './workspaces.ts'
91
103
 
92
104
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
@@ -127,6 +139,29 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
127
139
  const accessGroupsRaw = domain.table('access_groups')
128
140
  const accessUsersRaw = domain.table('access_users')
129
141
  const accessRequestsRaw = domain.table('access_requests')
142
+ // Pending attachment metadata is a table ADDED to the domain at the same
143
+ // version (see `pendingAttachmentRecordSchema`); hosts that cannot accept the
144
+ // addition leave this undefined and the registry degrades to its memory TTL
145
+ // fallback without taking the whole storage domain down.
146
+ let pendingAttachmentsRaw: ReturnType<RawDomainHandle['table']> | undefined
147
+ try {
148
+ pendingAttachmentsRaw = domain.table('pending_attachments')
149
+ } catch (error) {
150
+ ctx.logger?.warn?.('dsh-channels: pending attachment table unavailable: %s', String(error))
151
+ }
152
+ const pendingAttachments: PendingAttachmentsTable | undefined = pendingAttachmentsRaw === undefined
153
+ ? undefined
154
+ : {
155
+ get: key => pendingAttachmentsRaw.get(key) as PendingAttachmentRecord | undefined,
156
+ put: (key, value) => pendingAttachmentsRaw.put(key, value),
157
+ update: (key, fn) => pendingAttachmentsRaw.update(
158
+ key,
159
+ current => fn(current as PendingAttachmentRecord),
160
+ ),
161
+ delete: key => pendingAttachmentsRaw.delete(key),
162
+ entries: () =>
163
+ pendingAttachmentsRaw.entries() as IterableIterator<[string, PendingAttachmentRecord]>,
164
+ }
130
165
  return {
131
166
  routes: {
132
167
  get: key => table.get(key) as RouteRecord | undefined,
@@ -170,6 +205,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
170
205
  delete: key => accessRequestsRaw.delete(key),
171
206
  entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
172
207
  },
208
+ pendingAttachments,
173
209
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
174
210
  setGlobal: value => domain.global.set(value),
175
211
  close: () => domain.close(),
@@ -270,10 +306,36 @@ export class FeishuService {
270
306
  private readonly delivery: CardDelivery
271
307
  /** Inbound attachment archival (Feishu resources → workspace files). */
272
308
  private readonly attachments: AttachmentIntake
309
+ /**
310
+ * Backend-selecting inbound storage facade. The native path stays lazy in
311
+ * admission (prefetch images + manifest); this facade owns the legacy
312
+ * workspace download used by `attachmentStore: 'archive'` and by the
313
+ * fallback when the host does not mount `ctx.attachments`.
314
+ */
315
+ private readonly inboundAttachmentStore: InboundAttachmentStore
316
+ /**
317
+ * Silently registered attachment metadata (`pending_attachments` table):
318
+ * admission-layer registration, prompt manifest reads, fetch authorization
319
+ * whitelist, and the TTL/cap sweep wired into the periodic record GC.
320
+ */
321
+ private readonly pendingAttachments: PendingAttachmentStore
322
+ /**
323
+ * Read-only Feishu message access (P2): the quoted-message excerpt and the
324
+ * first-turn topic history. Constructed before the inbound router so the
325
+ * turn assembly can reach it; it resolves the live channel lazily.
326
+ */
327
+ private readonly messageReader = new MessageReader({ channel: () => this.channel })
273
328
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
274
329
  private readonly inbound: InboundRouter
275
330
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
276
331
  private readonly sessions: SessionManager
332
+ /**
333
+ * Model-facing Feishu tools. `feishu_attachment_fetch` plus the shared
334
+ * per-route known-message whitelist the admission, quote and history layers
335
+ * populate; `feishu_topic_messages` reads the current topic on demand.
336
+ */
337
+ private readonly attachmentFetchTools: AttachmentFetchToolRuntime
338
+ private readonly topicMessagesTools: TopicMessagesToolRuntime
277
339
  /** FIFO admission for ordinary turns and commands targeting one route. */
278
340
  private readonly routeOperations = new RouteOperationQueue()
279
341
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -298,6 +360,10 @@ export class FeishuService {
298
360
  private runningConfig: FeishuConfig | undefined
299
361
  /** Guards overlapping panel-triggered reconnects. */
300
362
  private restarting = false
363
+ /** One-shot warning for a native deployment without the host attachment library. */
364
+ private warnedNativeAttachmentsUnmounted = false
365
+ /** One-shot warning for silently degraded quote/history reads (missing scope). */
366
+ private warnedPromptReadDegraded = false
301
367
  private unlisten: (() => void) | undefined
302
368
  /** Disposers for host-scoped listeners registered by `start()`. */
303
369
  private readonly unlistenHost: Array<() => boolean> = []
@@ -355,6 +421,24 @@ export class FeishuService {
355
421
  persistRoute: (key, record) => this.routes.put(key, record),
356
422
  started: () => this.started,
357
423
  detachStream: sessionId => this.streamer?.detach(sessionId),
424
+ mountTools: (agentCtx, sessionId) => {
425
+ // The fetch tool only makes sense for the lazy native backend: in
426
+ // archive deployments (explicit, missing host library, or fetch tool
427
+ // disabled) every attachment is downloaded before the turn and the
428
+ // Agent reads the workspace copy, so mounting the tool would only
429
+ // advertise an operation that can never be authorized.
430
+ //
431
+ // The topic-messages tool is backend-independent: quoted excerpts and
432
+ // topic history are read-only, so it is mounted for both backends and
433
+ // self-gates on `feishuTools.topicMessages`. Both mounts start
434
+ // together and resolve as one promise (no serial blocking).
435
+ const mounts: Array<Promise<void>> = []
436
+ if (this.effectiveAttachmentBackend() === 'native') {
437
+ mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
438
+ }
439
+ mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
440
+ return Promise.all(mounts).then(() => undefined)
441
+ },
358
442
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
359
443
  })
360
444
  this.attachments = new AttachmentIntake({
@@ -362,6 +446,65 @@ export class FeishuService {
362
446
  channel: () => this.channel,
363
447
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
364
448
  })
449
+ this.inboundAttachmentStore = new InboundAttachmentStore({
450
+ enabled: () => this.config.acceptAttachments,
451
+ // Effective backend, not the raw config: `store()` is only called after
452
+ // admission already decided `archive`, so a native-configured deployment
453
+ // with the fetch tool disabled must download to the workspace here too.
454
+ mode: () => this.effectiveAttachmentBackend(),
455
+ service: () => this.nativeAttachmentService(),
456
+ channel: () => this.channel,
457
+ archive: this.attachments,
458
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
459
+ })
460
+ this.pendingAttachments = new PendingAttachmentStore({
461
+ // Undefined storage domain/table → the registry's memory TTL fallback.
462
+ table: () => this.domain?.pendingAttachments,
463
+ enabled: () => this.config.acceptAttachments,
464
+ ttlMs: () => this.config.pendingAttachmentTtlMs,
465
+ maxPerScope: () => this.config.pendingAttachmentLimit,
466
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
467
+ })
468
+ this.attachmentFetchTools = new AttachmentFetchToolRuntime({
469
+ config: () => this.config,
470
+ channel: () => this.channel,
471
+ pending: () => this.pendingAttachments,
472
+ routeOfSession: sessionId => {
473
+ const record = this.routes.findBySession(sessionId)
474
+ if (record === undefined) return undefined
475
+ return {
476
+ key: record.key,
477
+ kind: record.kind,
478
+ chatId: record.chatId,
479
+ chatType: record.chatType,
480
+ senderOpenId: record.senderOpenId,
481
+ }
482
+ },
483
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
484
+ })
485
+ this.topicMessagesTools = new TopicMessagesToolRuntime({
486
+ config: () => this.config,
487
+ channel: () => this.channel,
488
+ routeOfSession: sessionId => {
489
+ const record = this.routes.findBySession(sessionId)
490
+ if (record === undefined) return undefined
491
+ return {
492
+ key: record.key,
493
+ kind: record.kind,
494
+ chatId: record.chatId,
495
+ chatType: record.chatType,
496
+ senderOpenId: record.senderOpenId,
497
+ }
498
+ },
499
+ noteKnownMessage: (route, message, source) => {
500
+ this.attachmentFetchTools.noteKnownMessage(route, message, source)
501
+ },
502
+ isKnownMessage: (route, messageId) => this.attachmentFetchTools.isKnownMessage(route, messageId),
503
+ // The tool description must not promise a fetch tool that archive
504
+ // deployments do not mount; the runtime appends a degradation note.
505
+ fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
506
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
507
+ })
365
508
  this.delivery = new CardDelivery({
366
509
  channel: () => this.channel,
367
510
  started: () => this.started,
@@ -397,7 +540,23 @@ export class FeishuService {
397
540
  repair: this.repair,
398
541
  delivery: this.delivery,
399
542
  sessions: this.sessions,
400
- attachments: this.attachments,
543
+ attachmentMode: () => this.config.attachmentStore,
544
+ nativeAttachments: () => this.nativeAttachmentService(),
545
+ attachmentStore: this.inboundAttachmentStore,
546
+ pendingAttachments: this.pendingAttachments,
547
+ noteKnownAttachments: (record, message, source) => {
548
+ this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
549
+ },
550
+ messageRead: {
551
+ quote: input => this.readQuoteContext(input),
552
+ history: input => this.readHistoryContext(input),
553
+ },
554
+ prefetchImages: () => ({
555
+ enabled: this.config.prefetchImages,
556
+ maxImageBytes: this.config.prefetchImageMaxBytes,
557
+ maxTotalBytes: this.config.prefetchTotalMaxBytes,
558
+ timeoutMs: this.config.prefetchTimeoutMs,
559
+ }),
401
560
  access: this.accessController,
402
561
  queue: this.routeOperations,
403
562
  streamer: () => this.streamer,
@@ -406,6 +565,222 @@ export class FeishuService {
406
565
  })
407
566
  }
408
567
 
568
+ /** Effective inbound attachment backend (matches the inbound router). */
569
+ private effectiveAttachmentBackend(): 'native' | 'archive' {
570
+ return effectiveAttachmentMode(
571
+ this.config.attachmentStore,
572
+ this.nativeAttachmentService(),
573
+ this.config.feishuTools.attachmentFetch,
574
+ this.config.acceptAttachments,
575
+ )
576
+ }
577
+
578
+ /**
579
+ * Resolve the host's native attachment library from the plugin scope.
580
+ *
581
+ * The service is registered host-wide (`@deepseek-ai/dsh-attachment-local`),
582
+ * so the safe `ctx.get` lookup resolves it even though this plugin does not
583
+ * declare `attachments` in its cordis `inject` list (the per-Agent scopes
584
+ * still inject it for the fetch tool). A missing service reads as
585
+ * `undefined` and switches admission to the legacy archive backend.
586
+ */
587
+ private nativeAttachmentService(): AttachmentStore | undefined {
588
+ const service = resolveAttachmentService(this.ctx)
589
+ if (service === undefined && this.config.attachmentStore === 'native'
590
+ && !this.warnedNativeAttachmentsUnmounted) {
591
+ // The facade now receives the effective backend, so its own downgrade
592
+ // warning cannot fire: report the missing library once from the resolver.
593
+ this.warnedNativeAttachmentsUnmounted = true
594
+ this.ctx.logger?.warn?.(
595
+ 'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,'
596
+ + '入站附件回退话题 attachments/ 工作区落盘',
597
+ )
598
+ }
599
+ return service
600
+ }
601
+
602
+ /**
603
+ * Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
604
+ *
605
+ * The admission and quote layers call this after their access gates for the
606
+ * triggering / quoted message; the tool otherwise only authorizes messages
607
+ * visible in the durable pending-attachment registry. Registering is a pure
608
+ * in-memory operation (one LRU entry per route) and never fails the caller.
609
+ */
610
+ noteKnownAttachmentMessage(
611
+ record: RouteRecord,
612
+ message: KnownAttachmentMessageInput,
613
+ source: KnownAttachmentSource = 'trigger',
614
+ ): void {
615
+ this.attachmentFetchTools.noteKnownMessage({
616
+ key: record.key,
617
+ kind: record.kind,
618
+ chatId: record.chatId,
619
+ chatType: record.chatType,
620
+ senderOpenId: record.senderOpenId,
621
+ }, message, source)
622
+ }
623
+
624
+ // ---- read-only message context (quoted excerpt / topic history) -------
625
+
626
+ /**
627
+ * Channel-plane identity of one route for message reads (structurally the
628
+ * same as the attachment-tool route: key/kind/chat/sender).
629
+ */
630
+ private messageReadRouteOf(record: RouteRecord): MessageReadRoute {
631
+ return {
632
+ key: record.key,
633
+ kind: record.kind,
634
+ chatId: record.chatId,
635
+ chatType: record.chatType,
636
+ senderOpenId: record.senderOpenId,
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Resolve one quoted message into the bounded `<feishu_quote>` block and the
642
+ * whitelist registration of the quoted message (merged-forward containers
643
+ * keep their container id, per the P0 download rule).
644
+ *
645
+ * The quoted message must belong to this route's visible container: another
646
+ * chat is always rejected; on a thread route a quoted message of a
647
+ * *different* topic is rejected as well (a main-timeline message carries no
648
+ * thread id and stays allowed — quotes happen before the bot owns a topic);
649
+ * on a degraded chat route any topic message is rejected (a chat route must
650
+ * not read another topic of the same group).
651
+ *
652
+ * The read budget is the short prompt-enrichment budget, never the model
653
+ * tool timeout. Read failures (missing scope, deleted message, channel down)
654
+ * degrade to `undefined`: the turn runs without an excerpt (AC6).
655
+ */
656
+ private async readQuoteContext(input: {
657
+ messageId: string
658
+ record: RouteRecord
659
+ }): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
660
+ try {
661
+ const result = await readQuoteItems(this.messageReader, input.messageId, {
662
+ timeoutMs: this.config.promptContextTimeoutMs,
663
+ })
664
+ if (!result.ok) {
665
+ this.reportDegradedRead('quoted message', input.messageId, result.reason)
666
+ return undefined
667
+ }
668
+ const container: RawMessageItem | undefined = quoteItemsOf(result.value)
669
+ if (container === undefined) return undefined
670
+ if (!this.quotedMessageBelongsToRoute(input.record, container)) {
671
+ this.ctx.logger?.debug?.(
672
+ 'dsh-channels: dropping quoted message %s from another chat/topic', input.messageId,
673
+ )
674
+ return undefined
675
+ }
676
+ const block = buildQuoteContextBlock(result.value, {
677
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
678
+ })
679
+ if (block === '') return undefined
680
+ const message = knownMessageOfItems(result.value)
681
+ return { block, ...(message === undefined ? {} : { message }) }
682
+ } catch (error) {
683
+ // Defensive: the reader reports structured failures; a raw throw is still
684
+ // only ever a missing excerpt, never a failed turn.
685
+ this.ctx.logger?.debug?.(
686
+ 'dsh-channels: reading quoted message %s failed: %s', input.messageId, String(error),
687
+ )
688
+ return undefined
689
+ }
690
+ }
691
+
692
+ /** Whether one quoted message may be shown/authorized for one route. */
693
+ private quotedMessageBelongsToRoute(record: RouteRecord, container: RawMessageItem): boolean {
694
+ // Fail closed on a missing/foreign chat id: every real Feishu item carries
695
+ // one, so an absent id is a malformed response, never a legitimate quote.
696
+ if (container.chatId === undefined || container.chatId !== record.chatId) return false
697
+ // A main-timeline message has no thread id; a message of another topic must
698
+ // never leak into this route's excerpt or whitelist. A degraded chat route
699
+ // (no topic) must reject topic messages entirely, even in the same chat.
700
+ if (record.kind === 'thread') {
701
+ return container.threadId === undefined || container.threadId === record.key
702
+ }
703
+ return container.threadId === undefined
704
+ }
705
+
706
+ /**
707
+ * Resolve the bounded first-turn `<feishu_history>` block plus the whitelist
708
+ * registrations of the history messages (chronological order).
709
+ *
710
+ * `limit` is `historyBootstrapLimit` (0/negative → no read at all). The page
711
+ * is requested newest-first so a long topic yields the most recent messages;
712
+ * excluded ids (the trigger and the streaming card), deleted messages, and
713
+ * messages outside the route container are filtered before the newest
714
+ * `limit` rows are reversed into chronological order for rendering. The read
715
+ * uses the short prompt-enrichment budget and degrades to `undefined` (AC6).
716
+ */
717
+ private async readHistoryContext(input: {
718
+ record: RouteRecord
719
+ limit: number
720
+ excludeMessageIds: readonly string[]
721
+ }): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined> {
722
+ if (!Number.isFinite(input.limit) || input.limit <= 0) return undefined
723
+ try {
724
+ const route = this.messageReadRouteOf(input.record)
725
+ const pageSize = clampInt(
726
+ input.limit + input.excludeMessageIds.length,
727
+ 1,
728
+ TOPIC_MESSAGES_MAX_LIMIT,
729
+ )
730
+ const result = await this.messageReader.listRoute(route, {
731
+ pageSize,
732
+ order: 'newest-first',
733
+ timeoutMs: this.config.promptContextTimeoutMs,
734
+ })
735
+ if (!result.ok) {
736
+ this.reportDegradedRead('topic history', input.record.key, result.reason)
737
+ return undefined
738
+ }
739
+ const excluded = new Set(input.excludeMessageIds)
740
+ const recent = result.value.items
741
+ .filter(item => !item.deleted && !excluded.has(item.messageId))
742
+ .slice(0, input.limit)
743
+ .reverse()
744
+ const block = buildHistoryContextBlock(recent, {
745
+ maxMessages: input.limit,
746
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
747
+ })
748
+ if (block === '') return undefined
749
+ return { block, messages: knownMessagesOfHistory(recent) }
750
+ } catch (error) {
751
+ this.ctx.logger?.debug?.(
752
+ 'dsh-channels: reading topic history of %s failed: %s', input.record.key, String(error),
753
+ )
754
+ return undefined
755
+ }
756
+ }
757
+
758
+ /** Whether this deployment mounts the lazy attachment fetch tool. */
759
+ private attachmentFetchMounted(): boolean {
760
+ return this.effectiveAttachmentBackend() === 'native'
761
+ }
762
+
763
+ /**
764
+ * Report one silently degraded prompt read: debug per occurrence (expected
765
+ * when a deployment misses a scope) plus ONE warn per service lifetime so an
766
+ * operator can find the cause without log spam.
767
+ */
768
+ private reportDegradedRead(kind: string, target: string, reason: string): void {
769
+ if (this.warnedPromptReadDegraded) {
770
+ this.ctx.logger?.debug?.(
771
+ 'dsh-channels: %s read of %s failed (degraded): %s', kind, target, reason,
772
+ )
773
+ return
774
+ }
775
+ this.warnedPromptReadDegraded = true
776
+ this.ctx.logger?.warn?.(
777
+ 'dsh-channels: %s(%s)读取失败,已静默降级(不带引用/历史上下文继续回合):%s。'
778
+ + '如持续出现请检查飞书应用的消息读取权限(如 im:message:readonly / im:message.group_msg),'
779
+ + '或确认该消息未被撤回、网络正常',
780
+ kind, target, reason,
781
+ )
782
+ }
783
+
409
784
  // ---- lifecycle / Web settings panel ----------------------------------
410
785
 
411
786
  /**
@@ -511,7 +886,7 @@ export class FeishuService {
511
886
  /** Re-read the Web overlay over the profile config (no channel touch). */
512
887
  private async loadOverlay(): Promise<void> {
513
888
  const overlay = this.overlay === undefined ? {} : await this.overlay.load()
514
- this.replaceConfig({ ...this.baseConfig, ...overlay })
889
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
515
890
  }
516
891
 
517
892
  /**
@@ -530,8 +905,8 @@ export class FeishuService {
530
905
  * Apply an already-validated overlay over the profile config. Fields read
531
906
  * per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
532
907
  */
533
- applyOverlay(overlay: Partial<FeishuConfig>): void {
534
- this.replaceConfig({ ...this.baseConfig, ...overlay })
908
+ applyOverlay(overlay: OverlayPatch): void {
909
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
535
910
  this.refreshRetentionTimer()
536
911
  }
537
912
 
@@ -880,6 +1255,7 @@ export class FeishuService {
880
1255
  retireRoute: async route => {
881
1256
  if (route.kind === 'thread') await this.routes.retire(route.key)
882
1257
  else await this.routes.remove(route.key)
1258
+ this.attachmentFetchTools.forgetRoute(route.key)
883
1259
  },
884
1260
  warn: message => this.ctx.logger?.warn?.(message),
885
1261
  }
@@ -1258,6 +1634,7 @@ export class FeishuService {
1258
1634
  this.retentionTimer = undefined
1259
1635
  }
1260
1636
  await this.sessions.disposeAll()
1637
+ this.attachmentFetchTools.clear()
1261
1638
  this.routeOperations.clear()
1262
1639
  this.inbound.clear()
1263
1640
  this.routes.clear()
@@ -2018,13 +2395,18 @@ export class FeishuService {
2018
2395
 
2019
2396
 
2020
2397
  /**
2021
- * GC pass over the routing domain. Two independent halves:
2398
+ * GC pass over the routing domain. Three independent parts:
2022
2399
  *
2023
2400
  * 1. Dedup records (unconditional): `processed` records and in-process
2024
2401
  * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
2025
2402
  * redelivery windows are minutes, so a day of retention is ample and
2026
2403
  * keeps the table bounded regardless of the route TTL setting.
2027
- * 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2404
+ * 2. Pending attachment metadata (unconditional): rows past
2405
+ * `pendingAttachmentTtlMs` and rows above `pendingAttachmentLimit` per
2406
+ * `(chat, thread?, sender)` scope are removed. Independent of the route
2407
+ * TTL: the registry keeps its own 7-day default even when route retirement
2408
+ * is disabled.
2409
+ * 3. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2028
2410
  * are retired into minimal persistent tombstones before their active
2029
2411
  * records are deleted. A later message in that visible topic is then
2030
2412
  * rejected instead of silently creating a context-free Session. Degraded
@@ -2057,6 +2439,18 @@ export class FeishuService {
2057
2439
  }
2058
2440
  this.inbound.pruneAccepted(processedCutoff)
2059
2441
 
2442
+ try {
2443
+ const pruned = await this.pendingAttachments.prune()
2444
+ if (pruned.removedExpired > 0 || pruned.removedOverLimit > 0) {
2445
+ this.ctx.logger?.info?.(
2446
+ 'dsh-channels: record GC removed %d expired and %d over-limit pending attachment record(s)',
2447
+ pruned.removedExpired, pruned.removedOverLimit,
2448
+ )
2449
+ }
2450
+ } catch (error) {
2451
+ this.ctx.logger?.warn?.('dsh-channels: pending attachment GC failed: %s', String(error))
2452
+ }
2453
+
2060
2454
  const ttlMs = this.config.routeTtlMs
2061
2455
  if (ttlMs === 0) {
2062
2456
  if (removedProcessed > 0) {
@@ -2077,6 +2471,7 @@ export class FeishuService {
2077
2471
  } else {
2078
2472
  await this.routes.remove(key)
2079
2473
  }
2474
+ this.attachmentFetchTools.forgetRoute(key)
2080
2475
  removedRoutes++
2081
2476
  } catch (error) {
2082
2477
  this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
@@ -2084,6 +2479,19 @@ export class FeishuService {
2084
2479
  }
2085
2480
  }
2086
2481
 
2482
+ // Reconcile the attachment-fetch whitelist with the active route set: the
2483
+ // inbound TTL path retires a thread route directly (tombstone), so its
2484
+ // in-memory whitelist bucket would otherwise live until process exit.
2485
+ const droppedToolRoutes = this.attachmentFetchTools.pruneRoutes(
2486
+ routeKey => this.routes.get(routeKey) !== undefined,
2487
+ )
2488
+ if (droppedToolRoutes > 0) {
2489
+ this.ctx.logger?.info?.(
2490
+ 'dsh-channels: record GC dropped %d stale attachment-tool route bucket(s)',
2491
+ droppedToolRoutes,
2492
+ )
2493
+ }
2494
+
2087
2495
  const inflight = this.domain?.inflight
2088
2496
  if (inflight !== undefined) {
2089
2497
  for (const [key, snapshot] of [...inflight.entries()]) {