@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/README.md +71 -19
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +108 -2
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +9 -6
- package/lib/client.js +10 -5
- package/lib/client.js.map +1 -1
- package/lib/index.js +10193 -4824
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cleanup.ts +13 -2
- package/src/config.ts +194 -5
- package/src/domain.ts +107 -0
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +1 -1
- package/src/routing/inbound.ts +652 -48
- package/src/service.ts +417 -9
- package/src/session/manager.ts +66 -12
- package/src/settings/contract.ts +28 -10
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +137 -12
- package/src/workspaces.ts +25 -4
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
534
|
-
this.replaceConfig(
|
|
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.
|
|
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.
|
|
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()]) {
|