@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

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.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -0,0 +1,2305 @@
1
+ /**
2
+ * Model-facing Feishu tools: `feishu_attachment_fetch` (lazy attachment
3
+ * download) and `feishu_topic_messages` (quoted/topic history reads), plus the
4
+ * per-route authorization whitelist they share.
5
+ *
6
+ * Product shape (Roadmap attachment_pipeline_v2): a triggered message creates
7
+ * the Session immediately; files stay unloaded until the model asks for them.
8
+ * This module is the "ask" side: the model names a Feishu message id (and
9
+ * optionally one `file_key`), the tool downloads the resources through the
10
+ * existing channel wrappers, streams files into the DSH native attachment
11
+ * library (`ctx.attachments`) and returns content blocks the model can use.
12
+ *
13
+ * Security model: a message id is NOT a capability. Every call is authorized
14
+ * against a per-route whitelist of messages the channel has already admitted
15
+ * as relevant:
16
+ *
17
+ * - the triggering message, a quoted message, and history rows, registered by
18
+ * the inbound layer through {@link AttachmentFetchToolRuntime.noteKnownMessage};
19
+ * - messages this tool already resolved (successful or failed attempts), so a
20
+ * later turn in the same session keeps its authorized resources;
21
+ * - on a whitelist miss, a message the tool reads back from Feishu and finds to
22
+ * be the route owner's own message inside the route's container (its topic,
23
+ * or the main timeline of a degraded chat route); another topic never
24
+ * matches.
25
+ *
26
+ * The whitelist is an in-memory LRU per route and is not replayed from Session
27
+ * history; the read-back rule keeps the owner's earlier attachments fetchable
28
+ * after a host restart. A prompt-injected message id from another chat, user,
29
+ * or topic therefore cannot download anything.
30
+ *
31
+ * Failure model: every per-resource failure is a structured value (never a
32
+ * thrown raw error) so one bad resource does not abort the call and session
33
+ * history stays readable (the P0 `[object Object]` lesson). Cancellation and
34
+ * the whole-call timeout are reported with explicit codes instead.
35
+ *
36
+ * @module dsh-channels/feishu/tools
37
+ */
38
+
39
+ import { z } from 'zod'
40
+ import type { FeishuConfig } from '../config.ts'
41
+ import type {
42
+ AgentContext, ToolContentBlock, ToolDefinition, ToolExecution,
43
+ } from '../host/agent.ts'
44
+ import type {
45
+ AttachmentStore, FileAttachmentRef, ImageAttachmentRef,
46
+ } from '../host/attachments.ts'
47
+ import type { FeishuChannel, ResourceChunkStream } from '../feishu-channel.ts'
48
+ import { describeFailure } from './inbound-attachments.ts'
49
+ import {
50
+ MessageReader,
51
+ TOPIC_MESSAGES_DEFAULT_LIMIT,
52
+ TOPIC_MESSAGES_MAX_LIMIT,
53
+ clampInt,
54
+ describeReadError,
55
+ knownMessageOfItems,
56
+ messageBelongsToRoute,
57
+ quoteItemsOf,
58
+ topicMessageViewOf,
59
+ } from './message-read.ts'
60
+ import type {
61
+ MessageReadFailureCode, TopicMessageResourceView, TopicMessageView,
62
+ } from './message-read.ts'
63
+ import {
64
+ attachmentFailureReason, FALLBACK_MAX_IMAGE_BYTES, nativeAttachmentStoreOf,
65
+ resolveAttachmentService,
66
+ } from './native-attachments.ts'
67
+ import type {
68
+ AttachmentFailureCode, AttachmentResult, NativeAttachmentStore, StoredFile, StoredImage,
69
+ } from './native-attachments.ts'
70
+
71
+ // ---- Public identity ----------------------------------------------------
72
+
73
+ /** Model-facing tool name; stable across releases (session history references it). */
74
+ export const ATTACHMENT_FETCH_TOOL_NAME = 'feishu_attachment_fetch'
75
+
76
+ /**
77
+ * Default number of known messages kept per route (LRU). Sized well above one
78
+ * history page (≤50 rows): a dense attachment page plus the trigger/quote
79
+ * entries of the current turn must not evict each other.
80
+ */
81
+ export const DEFAULT_KNOWN_MESSAGES_PER_ROUTE = 128
82
+
83
+ /**
84
+ * Maximum time agent setup waits for the host `tools`/`attachments` services
85
+ * before degrading to "tool not mounted". A deployment without those services
86
+ * must not block session creation; if they appear later the pending injection
87
+ * still registers the tool.
88
+ */
89
+ export const MOUNT_INJECT_WAIT_MS = 2_000
90
+
91
+ /** Retry wait for later sessions once the first mount timed out (fast-fail). */
92
+ export const MOUNT_INJECT_RETRY_WAIT_MS = 250
93
+
94
+ // ---- Route / whitelist vocabulary ---------------------------------------
95
+
96
+ /**
97
+ * The channel-plane identity of one conversation route. The service derives it
98
+ * from the durable `RouteRecord`; all fields are needed to prove that a
99
+ * whitelist entry belongs to the route currently executing the tool.
100
+ */
101
+ export interface AttachmentFetchRoute {
102
+ /** Durable route key (thread id, or the degraded chat route key). */
103
+ readonly key: string
104
+ readonly kind: 'thread' | 'chat'
105
+ readonly chatId: string
106
+ readonly chatType: 'p2p' | 'group'
107
+ readonly senderUserId: string
108
+ }
109
+
110
+ /** Why a message became known to one route. */
111
+ export type KnownAttachmentSource =
112
+ | 'trigger'
113
+ | 'quote'
114
+ | 'tool-result'
115
+ | 'manual'
116
+ /** A message discovered while reading topic history/quote context (P2). */
117
+ | 'history'
118
+
119
+ /** One downloadable resource of a known message. */
120
+ export interface KnownAttachmentResource {
121
+ readonly type: 'image' | 'file'
122
+ readonly fileKey: string
123
+ readonly fileName?: string
124
+ /** Feishu folder entries cannot be downloaded; reported as unsupported. */
125
+ readonly folder?: boolean
126
+ /** Top-level merged-forward container id to download through, when different. */
127
+ readonly containerMessageId?: string
128
+ }
129
+
130
+ /** One message the current route may fetch attachments from. */
131
+ export interface KnownAttachmentMessage {
132
+ readonly messageId: string
133
+ readonly kind: 'message' | 'merge_forward'
134
+ readonly resources: readonly KnownAttachmentResource[]
135
+ /** Sender the message was attributed to when it entered the whitelist. */
136
+ readonly senderUserId?: string
137
+ }
138
+
139
+ /** Registration input for the whitelist. */
140
+ export interface KnownAttachmentMessageInput extends KnownAttachmentMessage {
141
+ readonly source?: KnownAttachmentSource
142
+ }
143
+
144
+ /** The thread id a route's `(chat, thread?, sender)` scope carries. */
145
+ export function threadIdOfRoute(route: AttachmentFetchRoute): string | undefined {
146
+ return route.kind === 'thread' ? route.key : undefined
147
+ }
148
+
149
+ /**
150
+ * Whether a whitelist entry was registered for exactly this route. Route keys
151
+ * are durable, but records can be replaced (a chat route re-created after a
152
+ * TTL) — re-checking chat, sender, chat type and key kind keeps an old entry
153
+ * from authorizing a new owner.
154
+ */
155
+ function routesMatch(left: AttachmentFetchRoute, right: AttachmentFetchRoute): boolean {
156
+ return left.key === right.key
157
+ && left.kind === right.kind
158
+ && left.chatId === right.chatId
159
+ && left.chatType === right.chatType
160
+ && left.senderUserId === right.senderUserId
161
+ }
162
+
163
+ interface WhitelistedMessage {
164
+ readonly route: AttachmentFetchRoute
165
+ readonly message: KnownAttachmentMessage
166
+ readonly source: KnownAttachmentSource
167
+ readonly registeredAt: number
168
+ }
169
+
170
+ /**
171
+ * Per-route LRU of messages the channel has declared relevant.
172
+ *
173
+ * The store is intentionally process-local: after a restart the inbound layer
174
+ * re-registers the current turn's messages, and the fetch tool's owner
175
+ * read-back authorizes the owner's earlier messages; Session history is never
176
+ * replayed into the whitelist.
177
+ */
178
+ export class RouteAttachmentWhitelist {
179
+ private readonly routes = new Map<string, Map<string, WhitelistedMessage>>()
180
+
181
+ constructor(private readonly limit: number = DEFAULT_KNOWN_MESSAGES_PER_ROUTE) {}
182
+
183
+ /** Register (or refresh) one known message for one route. */
184
+ register(
185
+ route: AttachmentFetchRoute,
186
+ message: KnownAttachmentMessage,
187
+ source: KnownAttachmentSource = 'manual',
188
+ now = Date.now(),
189
+ ): void {
190
+ let entries = this.routes.get(route.key)
191
+ if (entries === undefined) {
192
+ entries = new Map()
193
+ this.routes.set(route.key, entries)
194
+ }
195
+ // Re-insertion is the LRU touch: a refreshed message sorts last.
196
+ entries.delete(message.messageId)
197
+ entries.set(message.messageId, {
198
+ route: { ...route },
199
+ message: {
200
+ messageId: message.messageId,
201
+ kind: message.kind,
202
+ resources: message.resources.map(resource => ({ ...resource })),
203
+ ...(message.senderUserId === undefined ? {} : { senderUserId: message.senderUserId }),
204
+ },
205
+ source,
206
+ registeredAt: now,
207
+ })
208
+ while (entries.size > Math.max(1, this.limit)) {
209
+ const oldest = entries.keys().next()
210
+ if (oldest.done === true) break
211
+ entries.delete(oldest.value)
212
+ }
213
+ }
214
+
215
+ /** Look up one message for one route; misses for any other route. */
216
+ lookup(route: AttachmentFetchRoute, messageId: string): KnownAttachmentMessage | undefined {
217
+ const entries = this.routes.get(route.key)
218
+ const hit = entries?.get(messageId)
219
+ if (entries === undefined || hit === undefined) return undefined
220
+ if (!routesMatch(hit.route, route)) return undefined
221
+ // Touch for LRU recency.
222
+ entries.delete(messageId)
223
+ entries.set(messageId, hit)
224
+ return hit.message
225
+ }
226
+
227
+ /** Number of known messages (one route at a time, or all routes). */
228
+ size(routeKey?: string): number {
229
+ if (routeKey !== undefined) return this.routes.get(routeKey)?.size ?? 0
230
+ let total = 0
231
+ for (const entries of this.routes.values()) total += entries.size
232
+ return total
233
+ }
234
+
235
+ /** Every route key currently holding entries (route-GC sweep). */
236
+ routeKeys(): string[] {
237
+ return [...this.routes.keys()]
238
+ }
239
+
240
+ /** Drop every entry of one route (route retirement / tests). */
241
+ forget(routeKey: string): void {
242
+ this.routes.delete(routeKey)
243
+ }
244
+
245
+ /** Drop every route (shutdown / tests). */
246
+ clear(): void {
247
+ this.routes.clear()
248
+ }
249
+ }
250
+
251
+ // ---- Structured failures ------------------------------------------------
252
+
253
+ /** Stable failure codes this tool reports per resource or per call. */
254
+ export type AttachmentFetchFailureCode =
255
+ | AttachmentFailureCode
256
+ | 'ROUTE_NOT_FOUND'
257
+ | 'AUTHORIZATION_DENIED'
258
+ | 'CHANNEL_UNAVAILABLE'
259
+ | 'MESSAGE_READ_FAILED'
260
+ | 'NO_RESOURCES'
261
+ | 'UNKNOWN_FILE_KEY'
262
+ | 'RESOURCE_LIMIT'
263
+ | 'BYTES_LIMIT'
264
+ | 'TOOL_DISABLED'
265
+ | 'TIMEOUT'
266
+ | 'ABORTED'
267
+ | 'DOWNLOAD_FAILED'
268
+ | 'INTERNAL_ERROR'
269
+
270
+ const FETCH_FAILURE_REASONS: Readonly<Record<string, string>> = {
271
+ ROUTE_NOT_FOUND: '当前会话尚未绑定飞书对话,无法获取附件',
272
+ AUTHORIZATION_DENIED: '该消息不在本会话的附件授权范围内(仅可获取触发消息、引用消息、附件清单中列出的消息或本人在本话题发送的消息)',
273
+ CHANNEL_UNAVAILABLE: '飞书连接未运行,无法获取附件',
274
+ MESSAGE_READ_FAILED: '读取飞书消息内容失败',
275
+ NO_RESOURCES: '该消息不包含可下载的图片或文件',
276
+ UNKNOWN_FILE_KEY: '该 file_key 不属于这条消息',
277
+ RESOURCE_LIMIT: '本次消息的附件数量超过单次获取上限',
278
+ BYTES_LIMIT: '本次下载总字节超过单次上限',
279
+ TOOL_DISABLED: '附件获取工具已禁用',
280
+ TIMEOUT: '附件获取超时',
281
+ ABORTED: '附件获取已取消',
282
+ DOWNLOAD_FAILED: '飞书附件下载失败',
283
+ INTERNAL_ERROR: '附件获取失败',
284
+ }
285
+
286
+ /** Model-safe, user-facing reason for one failure code. */
287
+ export function attachmentFetchFailureReason(code: AttachmentFetchFailureCode): string {
288
+ const own = FETCH_FAILURE_REASONS[code]
289
+ if (own !== undefined) return own
290
+ const host = attachmentFailureReason(code as AttachmentFailureCode)
291
+ return typeof host === 'string' && host.length > 0 ? host : FETCH_FAILURE_REASONS.INTERNAL_ERROR ?? '附件获取失败'
292
+ }
293
+
294
+ /** One structured failure: a stable code plus a readable reason. */
295
+ export interface AttachmentFetchFailure {
296
+ readonly ok: false
297
+ readonly code: AttachmentFetchFailureCode
298
+ readonly reason: string
299
+ }
300
+
301
+ function failure(code: AttachmentFetchFailureCode, reason?: string): AttachmentFetchFailure {
302
+ return { ok: false, code, reason: reason ?? attachmentFetchFailureReason(code) }
303
+ }
304
+
305
+ // ---- Tool arguments -----------------------------------------------------
306
+
307
+ /**
308
+ * Runtime argument validation. The host projects
309
+ * {@link ATTACHMENT_FETCH_PARAMETERS} to the model but does not re-validate
310
+ * arguments against it (verified against dsh-tools 0.2.1-rc.2), so this zod
311
+ * schema is the authoritative argument gate; it also gives tests and
312
+ * embedded/non-native callers a typed value.
313
+ */
314
+ /** Feishu message id accepted from model arguments before any Feishu request. */
315
+ const MESSAGE_ID_PATTERN = /^om_[A-Za-z0-9_-]{1,128}$/
316
+
317
+ export const attachmentFetchArgsSchema = z.object({
318
+ message_id: z.string().min(1),
319
+ file_key: z.string().min(1).optional(),
320
+ }).strict()
321
+
322
+ export type AttachmentFetchArgs = z.infer<typeof attachmentFetchArgsSchema>
323
+
324
+ /** Model-facing parameter schema (hand-written; no DSH runtime import). */
325
+ export const ATTACHMENT_FETCH_PARAMETERS: Record<string, unknown> = {
326
+ type: 'object',
327
+ additionalProperties: false,
328
+ required: ['message_id'],
329
+ properties: {
330
+ message_id: {
331
+ type: 'string',
332
+ description: 'Feishu message id (om_...) of the message whose attachments to fetch.',
333
+ },
334
+ file_key: {
335
+ type: 'string',
336
+ description: 'Optional image/file key of one attachment of that message; omit to fetch every attachment it carries.',
337
+ },
338
+ },
339
+ }
340
+
341
+ // ---- Tool result value --------------------------------------------------
342
+
343
+ /** One fetched (or failed) resource in the tool result value. */
344
+ export interface AttachmentFetchResultItem {
345
+ readonly ok: boolean
346
+ readonly kind: 'image' | 'file'
347
+ readonly name?: string
348
+ readonly file_key?: string
349
+ readonly bytes?: number
350
+ readonly media_type?: string
351
+ readonly width?: number
352
+ readonly height?: number
353
+ /** Read-only host path of one stored image, when the backend maps one. */
354
+ readonly read_path?: string
355
+ /** Deterministic file handle text (name/bytes/digest/read-only path). */
356
+ readonly handle_text?: string
357
+ readonly code?: AttachmentFetchFailureCode
358
+ readonly reason?: string
359
+ readonly attachment?: ImageAttachmentRef | FileAttachmentRef
360
+ }
361
+
362
+ /** Tool output value: lossless JSON by construction (no `undefined` properties). */
363
+ export interface AttachmentFetchValue {
364
+ readonly message_id: string
365
+ readonly ok: boolean
366
+ readonly summary: string
367
+ readonly results: readonly AttachmentFetchResultItem[]
368
+ }
369
+
370
+ /** Model-facing output schema; mirrors {@link AttachmentFetchValue}. */
371
+ export const ATTACHMENT_FETCH_OUTPUT_SCHEMA: Record<string, unknown> = {
372
+ type: 'object',
373
+ additionalProperties: false,
374
+ required: ['message_id', 'ok', 'summary', 'results'],
375
+ properties: {
376
+ message_id: { type: 'string' },
377
+ ok: { type: 'boolean' },
378
+ summary: { type: 'string' },
379
+ results: {
380
+ type: 'array',
381
+ items: {
382
+ type: 'object',
383
+ additionalProperties: false,
384
+ required: ['ok', 'kind'],
385
+ properties: {
386
+ ok: { type: 'boolean' },
387
+ kind: { type: 'string', enum: ['image', 'file'] },
388
+ name: { type: 'string' },
389
+ file_key: { type: 'string' },
390
+ bytes: { type: 'number' },
391
+ media_type: { type: 'string' },
392
+ width: { type: 'number' },
393
+ height: { type: 'number' },
394
+ read_path: { type: 'string' },
395
+ handle_text: { type: 'string' },
396
+ code: { type: 'string' },
397
+ reason: { type: 'string' },
398
+ attachment: { type: 'object' },
399
+ },
400
+ },
401
+ },
402
+ },
403
+ }
404
+
405
+ // ---- Runtime dependency surface -----------------------------------------
406
+
407
+ /** Everything the tool runtime needs, injected by the service. */
408
+ export interface AttachmentFetchRuntimeDeps {
409
+ /** Live plugin configuration (switches, limits). */
410
+ config(): FeishuConfig
411
+ /** Live Feishu channel; undefined while disconnected/shut down. */
412
+ channel(): FeishuChannel | undefined
413
+ /** Resolve the route currently bound to one session. */
414
+ routeOfSession(sessionId: string): AttachmentFetchRoute | undefined
415
+ /** Structured warning sink (mount degradation, unexpected failures). */
416
+ warn(message: string, ...args: unknown[]): void
417
+ /** Clock seam for whitelist recency (tests). */
418
+ now?(): number
419
+ }
420
+
421
+ interface FetchCallContext {
422
+ readonly route: AttachmentFetchRoute
423
+ readonly args: AttachmentFetchArgs
424
+ readonly signal: AbortSignal
425
+ readonly timeout: { value: boolean }
426
+ readonly budget: DownloadBudget
427
+ readonly limits: { readonly maxResources: number }
428
+ readonly native: NativeAttachmentStore
429
+ readonly imageCap: number
430
+ readonly channel: FeishuChannel | undefined
431
+ }
432
+
433
+ interface DownloadBudget {
434
+ readonly maxBytes: number
435
+ usedBytes: number
436
+ /** Set once a chunk would push the call over its total byte budget. */
437
+ exceeded: boolean
438
+ }
439
+
440
+ /**
441
+ * The `feishu_attachment_fetch` runtime: mounts the tool into Agent scopes and
442
+ * owns the per-route whitelist that the admission/quote layers populate.
443
+ */
444
+ export class AttachmentFetchToolRuntime {
445
+ readonly whitelist: RouteAttachmentWhitelist
446
+ /** Agent scope → its one mount attempt (cordis callbacks get distinct ctx objects). */
447
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
448
+ private warnedMissingTools = false
449
+ private warnedMissingAttachments = false
450
+ private warnedMountFailure = false
451
+ private warnedUnavailable = false
452
+
453
+ constructor(private readonly deps: AttachmentFetchRuntimeDeps) {
454
+ this.whitelist = new RouteAttachmentWhitelist(DEFAULT_KNOWN_MESSAGES_PER_ROUTE)
455
+ }
456
+
457
+ /**
458
+ * Whether the feature switch currently allows tool registration/execution.
459
+ * `acceptAttachments=false` keeps inbound attachment messages inert, so a
460
+ * tool that only downloads them is disabled as well.
461
+ */
462
+ enabled(): boolean {
463
+ const config = this.deps.config()
464
+ return config.feishuTools.attachmentFetch && config.acceptAttachments
465
+ }
466
+
467
+ /**
468
+ * Pre-authorize one message for one route. The admission layer calls this for
469
+ * the triggering message; the quote layer calls it for quoted messages; this
470
+ * tool itself records messages it resolved. A no-op while the feature is
471
+ * disabled keeps a disabled runtime from accumulating state.
472
+ */
473
+ noteKnownMessage(
474
+ route: AttachmentFetchRoute,
475
+ message: KnownAttachmentMessageInput,
476
+ source: KnownAttachmentSource = message.source ?? 'manual',
477
+ ): void {
478
+ if (!this.enabled()) return
479
+ this.whitelist.register(route, message, source, this.now())
480
+ }
481
+
482
+ /**
483
+ * Whether one message id is in the route's whitelist. The
484
+ * `feishu_topic_messages` tool calls this to decide whether another sender's
485
+ * main-timeline message (no `thread_id`) may be read from the current topic
486
+ * route.
487
+ */
488
+ isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean {
489
+ return this.whitelist.lookup(route, messageId) !== undefined
490
+ }
491
+
492
+ /**
493
+ * Register the tool inside one Agent scope.
494
+ *
495
+ * Called from the agent-preset `setup` after the preset is mounted (the host
496
+ * composes tools inside presets). `inject(['tools', 'attachments'])` is the
497
+ * P0-proven registration path; the callback re-checks both services because
498
+ * a host may resolve the injection with a partially mounted scope. Missing
499
+ * services are a no-op with one warning; a mount failure never fails the
500
+ * session.
501
+ */
502
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
503
+ if (!this.enabled()) return
504
+ // Cordis hands the inject callback a DIFFERENT context object than the
505
+ // agent ctx, so dedupe on the outer scope and reuse its single attempt.
506
+ const existing = this.mountAttempts.get(agentCtx)
507
+ if (existing !== undefined) return await existing
508
+ const attempt = this.mountAttempt(agentCtx, sessionId)
509
+ this.mountAttempts.set(agentCtx, attempt)
510
+ await attempt
511
+ }
512
+
513
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
514
+ let injected = false
515
+ const outcome = await waitForAvailability(
516
+ Promise.resolve(agentCtx.inject(['tools', 'attachments'], (ctx) => {
517
+ injected = true
518
+ this.registerInScope(ctx, sessionId)
519
+ })),
520
+ // A deployment that already timed out once is unlikely to gain the
521
+ // services a moment later: fail fast on later sessions while the pending
522
+ // injection can still backfill registration if they do appear.
523
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
524
+ )
525
+ if (outcome === 'failed') {
526
+ this.warnMountFailure(`dsh-channels: mounting ${ATTACHMENT_FETCH_TOOL_NAME} failed`)
527
+ return
528
+ }
529
+ // Real cordis settles an injection whose dependencies never appear WITHOUT
530
+ // calling the callback; treat "settled but never injected" as unavailable.
531
+ // The fiber stays pending so a late service still backfills registration.
532
+ if (!injected && !this.warnedUnavailable) {
533
+ this.warnedUnavailable = true
534
+ this.deps.warn(
535
+ 'dsh-channels: %s 未挂载:宿主 tools/attachments 服务不可用(会话继续)',
536
+ ATTACHMENT_FETCH_TOOL_NAME,
537
+ )
538
+ }
539
+ }
540
+
541
+ /** Drop the whitelist entries of one retired route (record GC). */
542
+ forgetRoute(routeKey: string): void {
543
+ this.whitelist.forget(routeKey)
544
+ }
545
+
546
+ /**
547
+ * Drop whitelist routes that no longer resolve as active route records. The
548
+ * inbound TTL path retires a thread route without calling `forgetRoute`, so
549
+ * the periodic GC reconciles the whitelist afterwards.
550
+ * @returns number of route buckets dropped.
551
+ */
552
+ pruneRoutes(stillActive: (routeKey: string) => boolean): number {
553
+ let removed = 0
554
+ for (const key of this.whitelist.routeKeys()) {
555
+ if (stillActive(key)) continue
556
+ this.whitelist.forget(key)
557
+ removed += 1
558
+ }
559
+ return removed
560
+ }
561
+
562
+ /** Drop every whitelisted route (shutdown). */
563
+ clear(): void {
564
+ this.whitelist.clear()
565
+ }
566
+
567
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
568
+ // No per-callback-context dedupe: when a host service unloads/reloads,
569
+ // cordis disposes the injection fiber's effects (including our
570
+ // `tools.register`) and invokes this callback again with the same ctx
571
+ // object. Skipping the re-registration would silently lose the tool; a
572
+ // genuinely duplicated registration is caught below as a warning.
573
+ const tools = agentCtx.tools
574
+ if (tools === undefined || typeof tools.register !== 'function') {
575
+ if (!this.warnedMissingTools) {
576
+ this.warnedMissingTools = true
577
+ this.deps.warn(
578
+ 'dsh-channels: %s 未注册:宿主 tools 服务不可用(附件工具已禁用)',
579
+ ATTACHMENT_FETCH_TOOL_NAME,
580
+ )
581
+ }
582
+ return
583
+ }
584
+ const service = resolveAttachmentService(agentCtx)
585
+ if (service === undefined) {
586
+ if (!this.warnedMissingAttachments) {
587
+ this.warnedMissingAttachments = true
588
+ this.deps.warn(
589
+ 'dsh-channels: %s 未注册:宿主 attachments 服务不可用(附件工具已禁用)',
590
+ ATTACHMENT_FETCH_TOOL_NAME,
591
+ )
592
+ }
593
+ return
594
+ }
595
+ try {
596
+ tools.register(this.definitionFor(agentCtx, sessionId))
597
+ } catch (error) {
598
+ this.warnMountFailure(
599
+ `dsh-channels: registering ${ATTACHMENT_FETCH_TOOL_NAME} failed: ${String(error)}`,
600
+ )
601
+ }
602
+ }
603
+
604
+ private warnMountFailure(message: string): void {
605
+ if (this.warnedMountFailure) return
606
+ this.warnedMountFailure = true
607
+ this.deps.warn(message)
608
+ }
609
+
610
+ private now(): number {
611
+ return this.deps.now?.() ?? Date.now()
612
+ }
613
+
614
+ // ---- Tool definition --------------------------------------------------
615
+
616
+ private definitionFor(agentCtx: AgentContext, sessionId: string): ToolDefinition {
617
+ return {
618
+ name: ATTACHMENT_FETCH_TOOL_NAME,
619
+ description:
620
+ 'Fetch images/files attached to a known Feishu message in this conversation. '
621
+ + 'Images come back as viewable image content; files are saved as immutable read-only copies and returned with a readable path. '
622
+ + 'Without file_key every attachment of the message is fetched. '
623
+ + 'Only messages already known to this conversation (the triggering message, a quoted message, or an attachment listed in context) are accessible; unknown message ids are refused. '
624
+ + 'Calls are bounded by a per-call attachment count, total byte budget and timeout; per-attachment failures are reported without aborting the whole call.',
625
+ parameters: ATTACHMENT_FETCH_PARAMETERS,
626
+ output: {
627
+ schema: ATTACHMENT_FETCH_OUTPUT_SCHEMA,
628
+ render: (_args, value) => renderAttachmentFetchResult(value),
629
+ },
630
+ execute: async (rawArgs, exec) => await this.execute(agentCtx, sessionId, rawArgs, exec),
631
+ presentCall: args => presentAttachmentFetchCall(args),
632
+ presentResult: (_args, value) => presentAttachmentFetchResult(value),
633
+ isConcurrencySafe: () => false,
634
+ }
635
+ }
636
+
637
+ // ---- Execution --------------------------------------------------------
638
+
639
+ private async execute(
640
+ agentCtx: AgentContext,
641
+ sessionId: string,
642
+ rawArgs: Record<string, unknown>,
643
+ exec: ToolExecution,
644
+ ): Promise<AttachmentFetchValue> {
645
+ const parsed = attachmentFetchArgsSchema.safeParse(rawArgs)
646
+ if (!parsed.success) {
647
+ // A thrown, readable error is the correct shape for invalid arguments:
648
+ // the host turns it into a failed tool call with a text message (never a
649
+ // raw object rendered as `[object Object]`).
650
+ const issue = parsed.error.issues[0]
651
+ const path = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
652
+ throw new Error(`${ATTACHMENT_FETCH_TOOL_NAME}: invalid ${path}: ${issue?.message ?? 'invalid arguments'}`)
653
+ }
654
+ const args = parsed.data
655
+ const messageId = args.message_id
656
+ if (!this.enabled()) return callFailure(messageId, failure('TOOL_DISABLED'))
657
+
658
+ const route = this.deps.routeOfSession(sessionId)
659
+ if (route === undefined) {
660
+ return callFailure(messageId, failure('ROUTE_NOT_FOUND'))
661
+ }
662
+ const config = this.deps.config().feishuTools
663
+ const scope = createAbortScope(exec.signal, config.attachmentFetchTimeoutMs)
664
+ const budget: DownloadBudget = { maxBytes: config.attachmentFetchMaxBytes, usedBytes: 0, exceeded: false }
665
+ const call: FetchCallContext = {
666
+ route,
667
+ args,
668
+ signal: scope.signal,
669
+ timeout: scope.timeout,
670
+ budget,
671
+ limits: {
672
+ maxResources: config.attachmentFetchMaxResources,
673
+ },
674
+ native: nativeAttachmentStoreOf(agentCtx),
675
+ imageCap: imageByteCapOf(agentCtx),
676
+ channel: this.deps.channel(),
677
+ }
678
+ try {
679
+ return await this.fetch(call)
680
+ } catch (error) {
681
+ if (budget.exceeded) return callFailure(messageId, failure('BYTES_LIMIT'))
682
+ if (scope.signal.aborted) {
683
+ return callFailure(messageId, failure(scope.timeout.value ? 'TIMEOUT' : 'ABORTED'))
684
+ }
685
+ const reason = `${attachmentFetchFailureReason('INTERNAL_ERROR')}:${describeUnexpectedError(error)}`
686
+ this.deps.warn('dsh-channels: %s failed unexpectedly: %s', ATTACHMENT_FETCH_TOOL_NAME, reason)
687
+ return callFailure(messageId, { ok: false, code: 'INTERNAL_ERROR', reason })
688
+ } finally {
689
+ scope.dispose()
690
+ }
691
+ }
692
+
693
+ private async fetch(call: FetchCallContext): Promise<AttachmentFetchValue> {
694
+ const messageId = call.args.message_id
695
+ if (call.signal.aborted) {
696
+ return callFailure(messageId, failure(call.timeout.value ? 'TIMEOUT' : 'ABORTED'))
697
+ }
698
+
699
+ // 1. Authorize the message against this route's known resources.
700
+ const known = await this.resolveKnownMessage(call, messageId)
701
+ if (known === undefined) {
702
+ if (call.signal.aborted) {
703
+ return callFailure(messageId, failure(call.timeout.value ? 'TIMEOUT' : 'ABORTED'))
704
+ }
705
+ return callFailure(messageId, failure('AUTHORIZATION_DENIED'))
706
+ }
707
+
708
+ // 2. Resolve every resource (explicit list, or message/container expansion).
709
+ let resources = [...known.resources]
710
+ if (resources.length === 0) {
711
+ const expanded = await this.expandMessageResources(call, known)
712
+ if (!expanded.ok) return callFailure(messageId, expanded)
713
+ resources = expanded.resources
714
+ }
715
+ const allResources = resources
716
+ if (call.args.file_key !== undefined) {
717
+ const key = call.args.file_key
718
+ resources = allResources.filter(resource => resource.fileKey === key)
719
+ if (resources.length === 0) {
720
+ // Bound the listing: a merged-forward container may hold hundreds of
721
+ // child keys and the error text is model-visible.
722
+ const knownKeys = allResources.map(resource => resource.fileKey)
723
+ const shown = knownKeys.slice(0, 10)
724
+ const hidden = knownKeys.length - shown.length
725
+ const available = knownKeys.length === 0
726
+ ? ''
727
+ : `(可用:${shown.join('、')}${hidden > 0 ? ` 等 ${knownKeys.length} 个` : ''})`
728
+ return callFailure(messageId, failure(
729
+ 'UNKNOWN_FILE_KEY',
730
+ `${attachmentFetchFailureReason('UNKNOWN_FILE_KEY')}:${displayText(key)}${available}`,
731
+ ))
732
+ }
733
+ }
734
+ if (resources.length === 0) {
735
+ return callFailure(messageId, failure('NO_RESOURCES'))
736
+ }
737
+ if (resources.length > call.limits.maxResources) {
738
+ return callFailure(messageId, failure(
739
+ 'RESOURCE_LIMIT',
740
+ `${attachmentFetchFailureReason('RESOURCE_LIMIT')}(本消息 ${resources.length} 个,单次上限 ${call.limits.maxResources} 个;请用 file_key 分批获取)`,
741
+ ))
742
+ }
743
+
744
+ // 3. The message is authorized and has a usable resource set: remember it
745
+ // (with the resolved resources) for later turns of this route. This is
746
+ // an authorization fact, so it is recorded even if the downloads below
747
+ // fail and the model retries. Bookkeeping never fails the fetch.
748
+ this.rememberKnownMessage(call, known, allResources)
749
+
750
+ // 4. Fetch each resource, keeping per-resource failures as values.
751
+ const results: AttachmentFetchResultItem[] = []
752
+ for (let index = 0; index < resources.length; index += 1) {
753
+ const resource = resources[index]
754
+ if (resource === undefined) continue
755
+ if (call.signal.aborted) {
756
+ results.push(cancelledItem(resource, call.timeout.value))
757
+ for (const rest of resources.slice(index + 1)) results.push(cancelledItem(rest, call.timeout.value))
758
+ break
759
+ }
760
+ if (call.budget.exceeded) {
761
+ results.push(failedItem(resource, failure('BYTES_LIMIT')))
762
+ for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure('BYTES_LIMIT')))
763
+ break
764
+ }
765
+ if (call.channel === undefined) {
766
+ results.push(failedItem(resource, failure('CHANNEL_UNAVAILABLE')))
767
+ continue
768
+ }
769
+ results.push(await this.fetchResource(call, known, resource))
770
+ }
771
+
772
+ const fetched = results.filter(item => item.ok).length
773
+ const shownMessageId = displayText(messageId)
774
+ const summary = fetched > 0
775
+ ? `已获取 ${fetched}/${results.length} 个附件(消息 ${shownMessageId})`
776
+ : `${attachmentFetchFailureReason(
777
+ results.length === 0 ? 'NO_RESOURCES'
778
+ : (results[0]?.code ?? 'DOWNLOAD_FAILED'),
779
+ )}(消息 ${shownMessageId})`
780
+ return {
781
+ message_id: messageId,
782
+ ok: fetched > 0 && fetched === results.length,
783
+ summary,
784
+ results,
785
+ }
786
+ }
787
+
788
+ /** Whitelist first, then the owner read-back. */
789
+ private async resolveKnownMessage(
790
+ call: FetchCallContext,
791
+ messageId: string,
792
+ ): Promise<KnownAttachmentMessage | undefined> {
793
+ return this.whitelist.lookup(call.route, messageId) ?? await this.readOwnedMessage(call, messageId)
794
+ }
795
+
796
+ /**
797
+ * Authorize a whitelist miss by reading the message back from Feishu: the
798
+ * route owner's own, undeleted message that belongs to the route
799
+ * (`messageBelongsToRoute`) is authorized. Every read failure fails closed.
800
+ */
801
+ private async readOwnedMessage(
802
+ call: FetchCallContext,
803
+ messageId: string,
804
+ ): Promise<KnownAttachmentMessage | undefined> {
805
+ const channel = call.channel
806
+ if (channel === undefined) return undefined
807
+ // The id comes from model arguments: never send a malformed one to Feishu.
808
+ if (!MESSAGE_ID_PATTERN.test(messageId)) return undefined
809
+ const reader = new MessageReader({ channel: () => channel })
810
+ const read = await reader.readMessage(messageId, { signal: call.signal })
811
+ if (!read.ok) return undefined
812
+ const container = quoteItemsOf(read.value)
813
+ if (container === undefined || container.deleted) return undefined
814
+ if (container.senderUserId !== call.route.senderUserId) return undefined
815
+ if (!messageBelongsToRoute(call.route, container)) return undefined
816
+ return knownMessageOfItems(read.value)
817
+ }
818
+
819
+ /** Record the resolved message as known for later turns of this route. */
820
+ private rememberKnownMessage(
821
+ call: FetchCallContext,
822
+ message: KnownAttachmentMessage,
823
+ resources: readonly KnownAttachmentResource[],
824
+ ): void {
825
+ this.whitelist.register(
826
+ call.route,
827
+ { ...message, resources },
828
+ 'tool-result',
829
+ this.now(),
830
+ )
831
+ }
832
+
833
+ // ---- Resource expansion ----------------------------------------------
834
+
835
+ /**
836
+ * Read one known message through the channel's raw message API to discover
837
+ * its resources. This is the merged-forward path (P0: children are listed on
838
+ * the top-level container message, while their bytes download through the
839
+ * container id) and the fallback for trigger/quote registrations that carry
840
+ * only a message id.
841
+ */
842
+ private async expandMessageResources(
843
+ call: FetchCallContext,
844
+ message: KnownAttachmentMessage,
845
+ ): Promise<{ ok: true; resources: KnownAttachmentResource[] } | AttachmentFetchFailure> {
846
+ const channel = call.channel
847
+ if (channel === undefined) return failure('CHANNEL_UNAVAILABLE')
848
+ const messageGet = channel.messageGet
849
+ if (messageGet === undefined) {
850
+ return failure(
851
+ 'MESSAGE_READ_FAILED',
852
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:当前飞书通道不支持读取消息内容,无法展开附件`,
853
+ )
854
+ }
855
+ let response: unknown
856
+ try {
857
+ response = await cancellable(Promise.resolve(messageGet.call(channel, message.messageId)), call.signal)
858
+ } catch (error) {
859
+ if (isAbortError(error)) throw error
860
+ return failure(
861
+ 'MESSAGE_READ_FAILED',
862
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:${describeFetchError(error)}`,
863
+ )
864
+ }
865
+ const items = rawMessageItems(response)
866
+ if (items === undefined) {
867
+ return failure(
868
+ 'MESSAGE_READ_FAILED',
869
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:返回内容无法识别`,
870
+ )
871
+ }
872
+ const resources = message.kind === 'merge_forward'
873
+ ? mergedForwardResources(items)
874
+ : messageResourcesOfItems(items)
875
+ return { ok: true, resources }
876
+ }
877
+
878
+ // ---- Per-resource fetch ----------------------------------------------
879
+
880
+ private async fetchResource(
881
+ call: FetchCallContext,
882
+ message: KnownAttachmentMessage,
883
+ resource: KnownAttachmentResource,
884
+ ): Promise<AttachmentFetchResultItem> {
885
+ // `containerMessageId` is part of a trusted whitelist registration, never
886
+ // model input: the P0 rule makes
887
+ // merged-forward child bytes downloadable only through the top-level
888
+ // container id, so child ids are never used as download ids here.
889
+ const downloadId = resource.containerMessageId ?? message.messageId
890
+ if (resource.folder === true) {
891
+ return failedItem(resource, failure('FOLDER_UNSUPPORTED'))
892
+ }
893
+ if (resource.type === 'image') {
894
+ return await this.fetchImage(call, downloadId, resource)
895
+ }
896
+ return await this.fetchFile(call, downloadId, resource)
897
+ }
898
+
899
+ private async fetchImage(
900
+ call: FetchCallContext,
901
+ downloadId: string,
902
+ resource: KnownAttachmentResource,
903
+ ): Promise<AttachmentFetchResultItem> {
904
+ const channel = call.channel
905
+ if (channel === undefined) return failedItem(resource, failure('CHANNEL_UNAVAILABLE'))
906
+ const remaining = call.budget.maxBytes - call.budget.usedBytes
907
+ if (remaining <= 0) {
908
+ call.budget.exceeded = true
909
+ return failedItem(resource, failure('BYTES_LIMIT'))
910
+ }
911
+ const imageCap = call.imageCap
912
+ const limit = Math.min(imageCap, remaining)
913
+ let stream: ResourceChunkStream
914
+ try {
915
+ stream = await cancellable(Promise.resolve(channel.downloadResource(downloadId, resource.fileKey, 'image')), call.signal)
916
+ } catch (error) {
917
+ return downloadFailureItem(resource, error, call)
918
+ }
919
+ let bytes: Uint8Array
920
+ try {
921
+ bytes = await cancellable(
922
+ collectBoundedBytes(stream, call.signal, call.budget, limit, imageCap),
923
+ call.signal,
924
+ )
925
+ } catch (error) {
926
+ if (error instanceof BudgetExceededError) {
927
+ call.budget.exceeded = true
928
+ return failedItem(resource, failure('BYTES_LIMIT'))
929
+ }
930
+ if (error instanceof ImageTooLargeError) {
931
+ return failedItem(resource, failure('IMAGE_TOO_LARGE'))
932
+ }
933
+ return downloadFailureItem(resource, error, call)
934
+ }
935
+ let saved: AttachmentResult<StoredImage>
936
+ try {
937
+ saved = await cancellable(call.native.saveImage({
938
+ bytes,
939
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
940
+ }), call.signal)
941
+ } catch (error) {
942
+ return downloadFailureItem(resource, error, call)
943
+ }
944
+ if (!saved.ok) return failedItem(resource, saved)
945
+ const ref = saved.value.ref
946
+ const read = call.native.imageReadPath(ref)
947
+ return {
948
+ ok: true,
949
+ kind: 'image',
950
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
951
+ file_key: resource.fileKey,
952
+ bytes: ref.bytes,
953
+ media_type: ref.mediaType,
954
+ width: ref.width,
955
+ height: ref.height,
956
+ ...(read.ok ? { read_path: read.value } : {}),
957
+ attachment: ref,
958
+ }
959
+ }
960
+
961
+ private async fetchFile(
962
+ call: FetchCallContext,
963
+ downloadId: string,
964
+ resource: KnownAttachmentResource,
965
+ ): Promise<AttachmentFetchResultItem> {
966
+ const channel = call.channel
967
+ if (channel === undefined) return failedItem(resource, failure('CHANNEL_UNAVAILABLE'))
968
+ if (call.budget.maxBytes <= call.budget.usedBytes) {
969
+ call.budget.exceeded = true
970
+ return failedItem(resource, failure('BYTES_LIMIT'))
971
+ }
972
+ let stream: ResourceChunkStream
973
+ try {
974
+ stream = await cancellable(Promise.resolve(channel.downloadResource(downloadId, resource.fileKey, 'file')), call.signal)
975
+ } catch (error) {
976
+ return downloadFailureItem(resource, error, call)
977
+ }
978
+ const gated = budgetedChunks(stream, call.signal, call.budget)
979
+ let saved: AttachmentResult<StoredFile>
980
+ try {
981
+ saved = await cancellable(call.native.saveFile({
982
+ source: gated,
983
+ signal: call.signal,
984
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
985
+ }), call.signal)
986
+ } catch (error) {
987
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
988
+ return downloadFailureItem(resource, error, call)
989
+ }
990
+ if (!saved.ok) {
991
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
992
+ if (call.signal.aborted) return cancelledItem(resource, call.timeout.value)
993
+ return failedItem(resource, saved)
994
+ }
995
+ return {
996
+ ok: true,
997
+ kind: 'file',
998
+ name: saved.value.ref.name,
999
+ file_key: resource.fileKey,
1000
+ bytes: saved.value.ref.bytes,
1001
+ handle_text: saved.value.handleText,
1002
+ attachment: saved.value.ref,
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ /** Map one thrown download/store error into a structured item. */
1008
+ function downloadFailureItem(
1009
+ resource: KnownAttachmentResource,
1010
+ error: unknown,
1011
+ call: FetchCallContext,
1012
+ ): AttachmentFetchResultItem {
1013
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
1014
+ if (call.signal.aborted) return cancelledItem(resource, call.timeout.value)
1015
+ return failedItem(resource, failure(
1016
+ 'DOWNLOAD_FAILED',
1017
+ `${attachmentFetchFailureReason('DOWNLOAD_FAILED')}:${describeFetchError(error)}`,
1018
+ ))
1019
+ }
1020
+
1021
+ // ---- Download plumbing --------------------------------------------------
1022
+
1023
+ /**
1024
+ * Run one promise with cooperative cancellation: an aborted signal rejects the
1025
+ * wrapper immediately (the underlying operation is signalled too where the
1026
+ * host contract supports it). Late rejections are swallowed so a timed-out
1027
+ * tool call never produces an unhandled rejection.
1028
+ */
1029
+ function cancellable<T>(task: Promise<T>, signal: AbortSignal): Promise<T> {
1030
+ if (signal.aborted) {
1031
+ task.catch(() => {})
1032
+ return Promise.reject(abortError())
1033
+ }
1034
+ return new Promise<T>((resolve, reject) => {
1035
+ let settled = false
1036
+ const onAbort = (): void => {
1037
+ if (settled) return
1038
+ settled = true
1039
+ signal.removeEventListener('abort', onAbort)
1040
+ task.catch(() => {})
1041
+ reject(abortError())
1042
+ }
1043
+ signal.addEventListener('abort', onAbort, { once: true })
1044
+ task.then(
1045
+ (value) => {
1046
+ if (settled) return
1047
+ settled = true
1048
+ signal.removeEventListener('abort', onAbort)
1049
+ resolve(value)
1050
+ },
1051
+ (error: unknown) => {
1052
+ if (settled) return
1053
+ settled = true
1054
+ signal.removeEventListener('abort', onAbort)
1055
+ reject(error)
1056
+ },
1057
+ )
1058
+ })
1059
+ }
1060
+
1061
+ function abortError(): Error {
1062
+ const error = new Error('The operation was aborted')
1063
+ error.name = 'AbortError'
1064
+ return error
1065
+ }
1066
+
1067
+ function isAbortError(error: unknown): boolean {
1068
+ return typeof error === 'object' && error !== null
1069
+ && (error as { name?: unknown }).name === 'AbortError'
1070
+ }
1071
+
1072
+ /** Total budget for one tool call; also forwards caller/timeout cancellation. */
1073
+ function createAbortScope(caller: AbortSignal, timeoutMs: number): {
1074
+ signal: AbortSignal
1075
+ timeout: { value: boolean }
1076
+ dispose(): void
1077
+ } {
1078
+ const controller = new AbortController()
1079
+ const timeout = { value: false }
1080
+ const forward = (): void => { controller.abort() }
1081
+ if (caller.aborted) controller.abort()
1082
+ else caller.addEventListener('abort', forward, { once: true })
1083
+ const timer = setTimeout(() => {
1084
+ timeout.value = true
1085
+ controller.abort()
1086
+ }, timeoutMs)
1087
+ timer.unref?.()
1088
+ return {
1089
+ signal: controller.signal,
1090
+ timeout,
1091
+ dispose(): void {
1092
+ clearTimeout(timer)
1093
+ caller.removeEventListener('abort', forward)
1094
+ controller.abort()
1095
+ },
1096
+ }
1097
+ }
1098
+
1099
+ class BudgetExceededError extends Error {
1100
+ override readonly name = 'AttachmentFetchBudgetExceeded'
1101
+ }
1102
+
1103
+ class ImageTooLargeError extends Error {
1104
+ override readonly name = 'AttachmentFetchImageTooLarge'
1105
+ }
1106
+
1107
+ /** Byte cap for one image: the host's own limit is authoritative. */
1108
+ function imageByteCap(service: AttachmentStore | undefined): number {
1109
+ const max = service?.imageLimits?.maxImageBytes
1110
+ if (typeof max === 'number' && Number.isFinite(max) && max > 0) return max
1111
+ return FALLBACK_MAX_IMAGE_BYTES
1112
+ }
1113
+
1114
+ /** Read one image into memory under both the image cap and the call budget. */
1115
+ async function collectBoundedBytes(
1116
+ source: AsyncIterable<Uint8Array>,
1117
+ signal: AbortSignal,
1118
+ budget: DownloadBudget,
1119
+ limit: number,
1120
+ imageCap: number,
1121
+ ): Promise<Uint8Array> {
1122
+ const chunks: Uint8Array[] = []
1123
+ let ownBytes = 0
1124
+ for await (const chunk of source) {
1125
+ if (signal.aborted) throw abortError()
1126
+ ownBytes += chunk.byteLength
1127
+ budget.usedBytes += chunk.byteLength
1128
+ if (ownBytes > imageCap) throw new ImageTooLargeError()
1129
+ if (budget.usedBytes > budget.maxBytes || ownBytes > limit) throw new BudgetExceededError()
1130
+ chunks.push(chunk)
1131
+ }
1132
+ const bytes = new Uint8Array(ownBytes)
1133
+ let offset = 0
1134
+ for (const chunk of chunks) {
1135
+ bytes.set(chunk, offset)
1136
+ offset += chunk.byteLength
1137
+ }
1138
+ return bytes
1139
+ }
1140
+
1141
+ /** Files stream through the native library; the generator enforces the budget. */
1142
+ async function* budgetedChunks(
1143
+ source: AsyncIterable<Uint8Array>,
1144
+ signal: AbortSignal,
1145
+ budget: DownloadBudget,
1146
+ ): AsyncGenerator<Uint8Array> {
1147
+ const iterator = source[Symbol.asyncIterator]()
1148
+ try {
1149
+ while (true) {
1150
+ const next = await cancellable(iterator.next(), signal)
1151
+ if (next.done === true) return
1152
+ budget.usedBytes += next.value.byteLength
1153
+ if (budget.usedBytes > budget.maxBytes) {
1154
+ budget.exceeded = true
1155
+ throw new BudgetExceededError()
1156
+ }
1157
+ yield next.value
1158
+ }
1159
+ } finally {
1160
+ // Best-effort cleanup: a cancelled stream must not keep the underlying
1161
+ // Node stream open. Never awaited so a stalled source cannot hang the tool
1162
+ // call after the abort was already reported.
1163
+ try {
1164
+ void Promise.resolve(iterator.return?.()).catch(() => {})
1165
+ } catch {
1166
+ // A synchronous throw from a broken iterator is already moot here.
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ // ---- Item helpers -------------------------------------------------------
1172
+
1173
+ function failedItem(
1174
+ resource: KnownAttachmentResource,
1175
+ result: AttachmentFetchFailure,
1176
+ ): AttachmentFetchResultItem {
1177
+ return {
1178
+ ok: false,
1179
+ kind: resource.type,
1180
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
1181
+ file_key: resource.fileKey,
1182
+ code: result.code,
1183
+ reason: result.reason,
1184
+ }
1185
+ }
1186
+
1187
+ function cancelledItem(
1188
+ resource: KnownAttachmentResource,
1189
+ timedOut: boolean,
1190
+ ): AttachmentFetchResultItem {
1191
+ return failedItem(resource, failure(timedOut ? 'TIMEOUT' : 'ABORTED'))
1192
+ }
1193
+
1194
+ function callFailure(messageId: string, result: AttachmentFetchFailure): AttachmentFetchValue {
1195
+ return {
1196
+ message_id: messageId,
1197
+ ok: false,
1198
+ summary: `${result.reason}(消息 ${displayText(messageId)})`,
1199
+ results: [],
1200
+ }
1201
+ }
1202
+
1203
+ /** Cap for one untrusted error/file-name description placed into a result. */
1204
+ const MAX_DESCRIPTION_TEXT = 500
1205
+
1206
+ /**
1207
+ * Inline-safe, bounded text: control characters (newlines could forge extra
1208
+ * result lines) collapse to spaces and the length is bounded.
1209
+ */
1210
+ function sanitizeInlineText(value: string, max = MAX_DESCRIPTION_TEXT): string {
1211
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
1212
+ return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max)}…(已截断)`
1213
+ }
1214
+
1215
+ function truncateDescription(text: string): string {
1216
+ return sanitizeInlineText(text)
1217
+ }
1218
+
1219
+ /** Readable, bounded description for an unexpected (non-abort) throw. */
1220
+ function describeUnexpectedError(error: unknown): string {
1221
+ return truncateDescription(describeThrownValue(error))
1222
+ }
1223
+
1224
+ /**
1225
+ * Readable text for an arbitrary thrown value (never `[object Object]`).
1226
+ *
1227
+ * Plain-object rejects are common in the Feishu SDK (`{ code, msg }`). Only a
1228
+ * whitelist of descriptive fields is surfaced: serializing the whole object
1229
+ * could leak request headers or large bodies into the model-visible result.
1230
+ */
1231
+ function describeThrownValue(error: unknown): string {
1232
+ if (error instanceof Error && error.message.length > 0) return error.message
1233
+ if (typeof error === 'string' && error.length > 0) return error
1234
+ if (error === null || error === undefined) return '未知错误'
1235
+ if (typeof error === 'object') {
1236
+ const record = error as Record<string, unknown>
1237
+ const parts: string[] = []
1238
+ for (const key of ['code', 'status', 'statusCode', 'name', 'msg', 'message']) {
1239
+ const value = record[key]
1240
+ if (typeof value === 'string' && value.length > 0) parts.push(`${key}=${value}`)
1241
+ else if (typeof value === 'number' && Number.isFinite(value)) parts.push(`${key}=${value}`)
1242
+ }
1243
+ if (parts.length > 0) return parts.join(' ')
1244
+ return '未知错误(不可序列化的对象)'
1245
+ }
1246
+ return String(error)
1247
+ }
1248
+
1249
+ /** Readable, bounded download/store error text (Feishu 400/403 mapped). */
1250
+ function describeFetchError(error: unknown): string {
1251
+ const raw = describeThrownValue(error)
1252
+ return truncateDescription(describeFailure(new Error(raw)))
1253
+ }
1254
+
1255
+ /**
1256
+ * One untrusted display name for the model-facing text: control characters
1257
+ * (newlines could forge extra result lines) collapse to spaces and the length
1258
+ * is bounded; the structured value keeps the original name.
1259
+ */
1260
+ function displayText(value: string): string {
1261
+ return sanitizeInlineText(value, 200)
1262
+ }
1263
+
1264
+ // ---- Raw message parsing (merged forward / message.get) -----------------
1265
+
1266
+ interface RawMessageItem {
1267
+ readonly messageId?: string
1268
+ readonly msgType?: string
1269
+ readonly content?: unknown
1270
+ }
1271
+
1272
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
1273
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1274
+ ? value as Record<string, unknown>
1275
+ : undefined
1276
+ }
1277
+
1278
+ function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
1279
+ const value = record?.[key]
1280
+ return typeof value === 'string' && value.length > 0 ? value : undefined
1281
+ }
1282
+
1283
+ /**
1284
+ * `im.v1.message.get` response → message items. Tolerates the SDK envelope
1285
+ * (`{ data: { items } }`) and an unwrapped `{ items }` for stubs.
1286
+ */
1287
+ function rawMessageItems(response: unknown): RawMessageItem[] | undefined {
1288
+ const root = asRecord(response)
1289
+ const data = asRecord(root?.data) ?? root
1290
+ const items = data?.items
1291
+ if (!Array.isArray(items)) return undefined
1292
+ const parsed: RawMessageItem[] = []
1293
+ for (const item of items) {
1294
+ const record = asRecord(item)
1295
+ if (record === undefined) continue
1296
+ const body = asRecord(record.body)
1297
+ parsed.push({
1298
+ ...(stringField(record, 'message_id') === undefined ? {} : { messageId: stringField(record, 'message_id') }),
1299
+ ...(stringField(record, 'msg_type') === undefined ? {} : { msgType: stringField(record, 'msg_type') }),
1300
+ ...(body?.content === undefined ? {} : { content: body.content }),
1301
+ })
1302
+ }
1303
+ return parsed
1304
+ }
1305
+
1306
+ /** Parse Feishu's JSON-string message body content. */
1307
+ function parsedContent(content: unknown): Record<string, unknown> | undefined {
1308
+ if (typeof content === 'string') {
1309
+ try {
1310
+ return asRecord(JSON.parse(content))
1311
+ } catch {
1312
+ return undefined
1313
+ }
1314
+ }
1315
+ return asRecord(content)
1316
+ }
1317
+
1318
+ /** Resources of one regular (non merged-forward) message. */
1319
+ function messageResourcesOfItems(items: readonly RawMessageItem[]): KnownAttachmentResource[] {
1320
+ const resources: KnownAttachmentResource[] = []
1321
+ const seen = new Set<string>()
1322
+ for (const item of items) {
1323
+ for (const resource of resourcesOfItem(item)) {
1324
+ if (seen.has(resource.fileKey)) continue
1325
+ seen.add(resource.fileKey)
1326
+ resources.push(resource)
1327
+ }
1328
+ }
1329
+ return resources
1330
+ }
1331
+
1332
+ /**
1333
+ * Resources of a merged-forward container: every returned item is a child (the
1334
+ * top-level container itself carries no resource), and their bytes are fetched
1335
+ * through the container's own message id (P0 rule).
1336
+ */
1337
+ function mergedForwardResources(items: readonly RawMessageItem[]): KnownAttachmentResource[] {
1338
+ const resources: KnownAttachmentResource[] = []
1339
+ const seen = new Set<string>()
1340
+ for (const item of items) {
1341
+ for (const resource of resourcesOfItem(item)) {
1342
+ if (seen.has(resource.fileKey)) continue
1343
+ seen.add(resource.fileKey)
1344
+ resources.push(resource)
1345
+ }
1346
+ }
1347
+ return resources
1348
+ }
1349
+
1350
+ /** Extract downloadable resources from one raw message item. */
1351
+ function resourcesOfItem(item: RawMessageItem): KnownAttachmentResource[] {
1352
+ const content = parsedContent(item.content)
1353
+ if (content === undefined) return []
1354
+ switch (item.msgType) {
1355
+ case 'image': {
1356
+ const fileKey = stringField(content, 'image_key')
1357
+ return fileKey === undefined ? [] : [{ type: 'image', fileKey }]
1358
+ }
1359
+ case 'file': {
1360
+ const fileKey = stringField(content, 'file_key')
1361
+ if (fileKey === undefined) return []
1362
+ const fileName = stringField(content, 'file_name')
1363
+ return [{ type: 'file', fileKey, ...(fileName === undefined ? {} : { fileName }) }]
1364
+ }
1365
+ case 'post': {
1366
+ return postResources(content)
1367
+ }
1368
+ case 'folder': {
1369
+ const fileKey = stringField(content, 'file_key')
1370
+ if (fileKey === undefined) return []
1371
+ const fileName = stringField(content, 'file_name')
1372
+ return [{
1373
+ type: 'file',
1374
+ fileKey,
1375
+ folder: true,
1376
+ ...(fileName === undefined ? {} : { fileName }),
1377
+ }]
1378
+ }
1379
+ default:
1380
+ return []
1381
+ }
1382
+ }
1383
+
1384
+ /** Locale keys Feishu uses to wrap a localized post body. */
1385
+ const POST_LOCALES: ReadonlySet<string> = new Set([
1386
+ 'zh_cn', 'zh_hk', 'zh_tw', 'en_us', 'ja_jp', 'ko_kr', 'de_de', 'fr_fr',
1387
+ 'es_es', 'pt_br', 'ru_ru', 'th_th', 'vi_vn', 'id_id', 'it_it', 'ms_my',
1388
+ ])
1389
+
1390
+ /**
1391
+ * Resources embedded in a rich-text (`post`) body.
1392
+ *
1393
+ * Feishu serializes a post as `{ title, content, files }`, optionally wrapped
1394
+ * in one or more known locales (`{ zh_cn: { content, files }, ... }`). The
1395
+ * `content` rows carry inline `img`/`file`/`media` elements while attached
1396
+ * files live in the body's top-level `files[]` array that the SDK's post
1397
+ * converter drops — the same gap `postAttachedFiles` recovers on the inbound
1398
+ * path.
1399
+ *
1400
+ * Only the standard body shape is inspected: arbitrary nested `files`/`tag`
1401
+ * fields elsewhere in the body are NOT adopted, so the tool never treats
1402
+ * unrelated metadata as an attachment of an authorized message.
1403
+ */
1404
+ function postResources(content: Record<string, unknown>): KnownAttachmentResource[] {
1405
+ const resources: KnownAttachmentResource[] = []
1406
+ const byKey = new Map<string, KnownAttachmentResource>()
1407
+ const push = (resource: KnownAttachmentResource): void => {
1408
+ const existing = byKey.get(resource.fileKey)
1409
+ if (existing === undefined) {
1410
+ byKey.set(resource.fileKey, resource)
1411
+ resources.push(resource)
1412
+ return
1413
+ }
1414
+ // A folder marker from `files[]` must survive an inline duplicate.
1415
+ if (existing.folder !== true && resource.folder === true) {
1416
+ const index = resources.indexOf(existing)
1417
+ if (index !== -1) resources[index] = { ...existing, folder: true }
1418
+ }
1419
+ }
1420
+ for (const body of postBodies(content)) {
1421
+ const rows = body.content
1422
+ if (Array.isArray(rows)) {
1423
+ for (const row of rows) {
1424
+ if (!Array.isArray(row)) continue
1425
+ for (const rawElement of row) {
1426
+ const element = asRecord(rawElement)
1427
+ if (element === undefined) continue
1428
+ const tag = stringField(element, 'tag')
1429
+ if (tag === 'img') {
1430
+ const fileKey = stringField(element, 'image_key')
1431
+ if (fileKey !== undefined) push({ type: 'image', fileKey })
1432
+ continue
1433
+ }
1434
+ if (tag === 'file' || tag === 'media') {
1435
+ const fileKey = stringField(element, 'file_key')
1436
+ if (fileKey === undefined) continue
1437
+ const fileName = stringField(element, 'file_name')
1438
+ push({
1439
+ type: 'file',
1440
+ fileKey,
1441
+ ...(fileName === undefined ? {} : { fileName }),
1442
+ ...(element.is_folder === true ? { folder: true } : {}),
1443
+ })
1444
+ }
1445
+ }
1446
+ }
1447
+ }
1448
+ const files = body.files
1449
+ if (Array.isArray(files)) {
1450
+ for (const rawFile of files) {
1451
+ const file = asRecord(rawFile)
1452
+ if (file === undefined) continue
1453
+ const fileKey = stringField(file, 'file_key')
1454
+ if (fileKey === undefined) continue
1455
+ const fileName = stringField(file, 'file_name')
1456
+ push({
1457
+ type: 'file',
1458
+ fileKey,
1459
+ ...(fileName === undefined ? {} : { fileName }),
1460
+ ...(file.is_folder === true ? { folder: true } : {}),
1461
+ })
1462
+ }
1463
+ }
1464
+ }
1465
+ return resources
1466
+ }
1467
+
1468
+ /** The standard post body record(s): the direct shape plus known locale wrappers. */
1469
+ function postBodies(content: Record<string, unknown>): Array<Record<string, unknown>> {
1470
+ const bodies: Array<Record<string, unknown>> = []
1471
+ if (isPostBody(content)) bodies.push(content)
1472
+ for (const [key, value] of Object.entries(content)) {
1473
+ if (!POST_LOCALES.has(key)) continue
1474
+ const body = asRecord(value)
1475
+ if (body !== undefined && isPostBody(body)) bodies.push(body)
1476
+ }
1477
+ return bodies
1478
+ }
1479
+
1480
+ function isPostBody(record: Record<string, unknown>): boolean {
1481
+ return Array.isArray(record.content) || Array.isArray(record.files)
1482
+ }
1483
+
1484
+ /**
1485
+ * Wait for one cordis injection to settle, but never longer than `timeoutMs`:
1486
+ * a hard dependency on a service a deployment does not mount would otherwise
1487
+ * block agent creation forever.
1488
+ */
1489
+ export function waitForAvailability(
1490
+ task: PromiseLike<unknown>,
1491
+ timeoutMs: number,
1492
+ ): Promise<'ready' | 'failed' | 'timeout'> {
1493
+ return new Promise<'ready' | 'failed' | 'timeout'>((resolve) => {
1494
+ let settled = false
1495
+ const finish = (outcome: 'ready' | 'failed' | 'timeout'): void => {
1496
+ if (settled) return
1497
+ settled = true
1498
+ clearTimeout(timer)
1499
+ resolve(outcome)
1500
+ }
1501
+ const timer = setTimeout(() => finish('timeout'), timeoutMs)
1502
+ timer.unref?.()
1503
+ Promise.resolve(task).then(
1504
+ () => finish('ready'),
1505
+ () => finish('failed'),
1506
+ )
1507
+ })
1508
+ }
1509
+
1510
+ // ---- Rendering / presentation -------------------------------------------
1511
+
1512
+ function resourceLabel(item: AttachmentFetchResultItem): string {
1513
+ if (item.name !== undefined && item.name !== '') return displayText(item.name)
1514
+ if (item.file_key !== undefined) return displayText(item.file_key)
1515
+ return item.kind === 'image' ? '图片' : '文件'
1516
+ }
1517
+
1518
+ function imageMetadataLine(item: AttachmentFetchResultItem): string {
1519
+ const parts: string[] = []
1520
+ if (item.bytes !== undefined) parts.push(`${item.bytes} 字节`)
1521
+ if (item.width !== undefined && item.height !== undefined) parts.push(`${item.width}×${item.height}`)
1522
+ if (item.media_type !== undefined) parts.push(item.media_type)
1523
+ const metadata = parts.length === 0 ? '' : `(${parts.join(',')})`
1524
+ const path = item.read_path === undefined ? '' : `;只读路径:${item.read_path}`
1525
+ return `- 图片 ${resourceLabel(item)} 已获取${metadata}${path}`
1526
+ }
1527
+
1528
+ function resultTextLine(item: AttachmentFetchResultItem): string {
1529
+ if (item.ok) {
1530
+ if (item.kind === 'image') return imageMetadataLine(item)
1531
+ const handle = item.handle_text === undefined ? '' : ` ${item.handle_text}`
1532
+ return `- 文件 ${resourceLabel(item)}(${item.bytes ?? 0} 字节)已保存为只读副本。${handle}`.trimEnd()
1533
+ }
1534
+ return `- ${resourceLabel(item)} 获取失败(${item.reason ?? attachmentFetchFailureReason(item.code ?? 'DOWNLOAD_FAILED')})`
1535
+ }
1536
+
1537
+ /**
1538
+ * Turn one tool result into model content: a text block (summary, per-resource
1539
+ * metadata, file handle fallback) plus one image/file block per fetched
1540
+ * resource. The text path is always present so a host that cannot deliver file
1541
+ * blocks (or a text-only model) still gets a usable answer.
1542
+ */
1543
+ export function renderAttachmentFetchResult(value: unknown): readonly ToolContentBlock[] {
1544
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<AttachmentFetchValue>
1545
+ const items = (Array.isArray(result.results) ? result.results : []).filter(
1546
+ (item): item is AttachmentFetchResultItem => typeof item === 'object' && item !== null,
1547
+ )
1548
+ const summary = typeof result.summary === 'string' ? result.summary : ATTACHMENT_FETCH_TOOL_NAME
1549
+ const lines = [summary]
1550
+ for (const item of items) lines.push(resultTextLine(item))
1551
+ if (items.some(item => item.ok && item.kind === 'image')) {
1552
+ lines.push('若当前模型无法查看图片,请依据上面的元数据向用户说明,并请用户描述图片内容。')
1553
+ }
1554
+ const blocks: ToolContentBlock[] = [{ type: 'text', text: lines.join('\n') }]
1555
+ for (const item of items) {
1556
+ if (!item.ok || item.attachment === undefined) continue
1557
+ if (item.kind === 'image') blocks.push({ type: 'image', attachment: item.attachment })
1558
+ else blocks.push({ type: 'file', attachment: item.attachment })
1559
+ }
1560
+ return blocks
1561
+ }
1562
+
1563
+ /** Host-local card call projection: "fetch attachment of message X". */
1564
+ export function presentAttachmentFetchCall(args: Record<string, unknown>): string {
1565
+ const messageId = typeof args.message_id === 'string' ? args.message_id : ''
1566
+ const fileKey = typeof args.file_key === 'string' ? args.file_key : undefined
1567
+ return fileKey === undefined
1568
+ ? `获取飞书附件 ${messageId}`
1569
+ : `获取飞书附件 ${messageId}(${fileKey})`
1570
+ }
1571
+
1572
+ /** Host-local card result projection: the one-line outcome. */
1573
+ export function presentAttachmentFetchResult(value: unknown): string {
1574
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<AttachmentFetchValue>
1575
+ return typeof result.summary === 'string' ? result.summary : ATTACHMENT_FETCH_TOOL_NAME
1576
+ }
1577
+
1578
+ // ---- Exposed helper for the image-cap resolution ------------------------
1579
+
1580
+ /**
1581
+ * Resolve the live image byte cap of one Agent scope (the service may unmount
1582
+ * between registration and execution; a missing service keeps the fallback cap
1583
+ * so the caller can still report a structured "unmounted" failure).
1584
+ */
1585
+ function imageByteCapOf(agentCtx: AgentContext): number {
1586
+ return imageByteCap(resolveAttachmentService(agentCtx))
1587
+ }
1588
+
1589
+ // ---- Topic messages tool ------------------------------------------------
1590
+
1591
+ /** Model-facing tool name; stable across releases (session history references it). */
1592
+ export const TOPIC_MESSAGES_TOOL_NAME = 'feishu_topic_messages'
1593
+
1594
+ /**
1595
+ * Model-facing description of {@link TOPIC_MESSAGES_TOOL_NAME}: the vocabulary
1596
+ * the model needs to choose between one quoted-message read and paging through
1597
+ * the discussion that precedes the @.
1598
+ */
1599
+ const TOPIC_MESSAGES_DESCRIPTION =
1600
+ 'Read messages of the CURRENT Feishu conversation: the topic/thread history, or the chat main timeline on '
1601
+ + 'a degraded (non-topic) route. Pass message_id to read one message (a quoted message, or a merged-forward '
1602
+ + 'message which is expanded into its child messages); omit message_id to list the most recent messages '
1603
+ + 'newest-first and page with limit/cursor. Every message carries its message_id (usable with '
1604
+ + 'feishu_attachment_fetch), text, time, sender and attachment metadata. Only messages of this conversation '
1605
+ + 'are readable: a message id from another chat/topic, or one this session never admitted, is refused. '
1606
+ + 'Reading a message also authorizes its images/files for feishu_attachment_fetch. Output is bounded; use '
1607
+ + 'limit (default 10, maximum 50) and cursor to continue. Merged-forward child message ids are NOT '
1608
+ + 'downloadable; fetch their attachments with the top-level container message_id shown for the container row.'
1609
+
1610
+ /** Extra model guidance appended when this deployment cannot fetch attachments (archive backend). */
1611
+ const TOPIC_MESSAGES_FETCH_UNAVAILABLE_DESCRIPTION =
1612
+ ' In this deployment feishu_attachment_fetch is unavailable; report attachment metadata to the user '
1613
+ + 'instead of fetching.'
1614
+
1615
+ /**
1616
+ * Runtime argument validation of `feishu_topic_messages`.
1617
+ *
1618
+ * The host projects {@link TOPIC_MESSAGES_PARAMETERS} to the model but does not
1619
+ * re-validate arguments (the same finding as the attachment tool), so this zod
1620
+ * schema is the authoritative argument gate. `message_id` is shape-checked
1621
+ * (Feishu ids are `om_` + URL-safe characters; no path/query injection reaches
1622
+ * the read API), and `message_id` (single read) / `cursor` (list paging) are
1623
+ * mutually exclusive; the execute path rejects that combination with a readable
1624
+ * Chinese error rather than a schema message.
1625
+ */
1626
+ export const topicMessagesArgsSchema = z.object({
1627
+ message_id: z.string().min(1).max(160).regex(MESSAGE_ID_PATTERN).optional(),
1628
+ limit: z.number().int().min(1).max(TOPIC_MESSAGES_MAX_LIMIT).optional(),
1629
+ cursor: z.string().min(1).max(2048).optional(),
1630
+ }).strict()
1631
+
1632
+ export type TopicMessagesArgs = z.infer<typeof topicMessagesArgsSchema>
1633
+
1634
+ /** Model-facing parameter schema (hand-written; no DSH runtime import). */
1635
+ export const TOPIC_MESSAGES_PARAMETERS: Record<string, unknown> = {
1636
+ type: 'object',
1637
+ additionalProperties: false,
1638
+ properties: {
1639
+ message_id: {
1640
+ type: 'string',
1641
+ minLength: 1,
1642
+ maxLength: 160,
1643
+ pattern: '^om_[A-Za-z0-9_-]{1,128}$',
1644
+ description:
1645
+ 'Feishu message id (om_...) of one message to read. Omit to list the current topic/chat history instead.',
1646
+ },
1647
+ limit: {
1648
+ type: 'integer',
1649
+ minimum: 1,
1650
+ maximum: TOPIC_MESSAGES_MAX_LIMIT,
1651
+ default: TOPIC_MESSAGES_DEFAULT_LIMIT,
1652
+ description:
1653
+ 'Page size when listing history, default 10 and maximum 50. Ignored in message_id mode.',
1654
+ },
1655
+ cursor: {
1656
+ type: 'string',
1657
+ minLength: 1,
1658
+ maxLength: 2048,
1659
+ description:
1660
+ 'Opaque page token from a previous result (its cursor field) used to continue an older page. '
1661
+ + 'Only for list mode; never combine it with message_id.',
1662
+ },
1663
+ },
1664
+ }
1665
+
1666
+ // ---- Topic messages results ---------------------------------------------
1667
+
1668
+ /** Stable failure codes of `feishu_topic_messages` (one readable reason each). */
1669
+ export type TopicMessagesFailureCode =
1670
+ | 'ROUTE_NOT_FOUND'
1671
+ | 'TOOL_DISABLED'
1672
+ | 'INVALID_ARGUMENT'
1673
+ | 'MESSAGE_OUT_OF_ROUTE'
1674
+ | 'MESSAGE_READ_FAILED'
1675
+ | 'PERMISSION_DENIED'
1676
+ | 'ABORTED'
1677
+ | 'TIMEOUT'
1678
+ | 'INTERNAL_ERROR'
1679
+
1680
+ const TOPIC_MESSAGES_FAILURE_REASONS: Readonly<Record<TopicMessagesFailureCode, string>> = {
1681
+ ROUTE_NOT_FOUND: '当前会话尚未绑定飞书话题或聊天,无法读取消息',
1682
+ TOOL_DISABLED: '话题消息读取工具已禁用',
1683
+ INVALID_ARGUMENT: '读取消息的参数不合法',
1684
+ MESSAGE_OUT_OF_ROUTE:
1685
+ '该消息不在当前会话的飞书话题/聊天范围内(仅可读取当前话题的消息或本会话已知的消息)',
1686
+ MESSAGE_READ_FAILED: '读取飞书消息失败',
1687
+ PERMISSION_DENIED: '飞书应用缺少读取消息的权限(如 im:message:readonly),无法读取引用或话题消息',
1688
+ ABORTED: '读取飞书消息已取消',
1689
+ TIMEOUT: '读取飞书消息超时',
1690
+ INTERNAL_ERROR: '读取话题消息失败',
1691
+ }
1692
+
1693
+ /** `single` for one message_id read, `newest_first` for a history page. */
1694
+ export type TopicMessagesOrder = 'newest_first' | 'single'
1695
+
1696
+ /** The container the result belongs to (the current route's read scope). */
1697
+ export interface TopicMessagesContainer {
1698
+ readonly type: 'thread' | 'chat'
1699
+ readonly id: string
1700
+ }
1701
+
1702
+ /**
1703
+ * Result value of `feishu_topic_messages`. Lossless JSON by construction:
1704
+ * optional keys are omitted instead of set to `undefined`.
1705
+ */
1706
+ export interface TopicMessagesValue {
1707
+ readonly ok: boolean
1708
+ readonly summary: string
1709
+ readonly container: TopicMessagesContainer
1710
+ readonly order: TopicMessagesOrder
1711
+ readonly message_count: number
1712
+ readonly has_more: boolean
1713
+ /** Opaque page token of the next older page (`has_more` pages only). */
1714
+ readonly cursor?: string
1715
+ readonly messages: readonly TopicMessageView[]
1716
+ readonly code?: TopicMessagesFailureCode
1717
+ readonly reason?: string
1718
+ }
1719
+
1720
+ /** Model-facing output schema; mirrors {@link TopicMessagesValue}. */
1721
+ export const TOPIC_MESSAGES_OUTPUT_SCHEMA: Record<string, unknown> = {
1722
+ type: 'object',
1723
+ additionalProperties: false,
1724
+ required: ['ok', 'summary', 'container', 'order', 'message_count', 'has_more', 'messages'],
1725
+ properties: {
1726
+ ok: { type: 'boolean' },
1727
+ summary: { type: 'string' },
1728
+ container: {
1729
+ type: 'object',
1730
+ additionalProperties: false,
1731
+ required: ['type', 'id'],
1732
+ properties: {
1733
+ type: { type: 'string', enum: ['thread', 'chat'] },
1734
+ id: { type: 'string' },
1735
+ },
1736
+ },
1737
+ order: { type: 'string', enum: ['newest_first', 'single'] },
1738
+ message_count: { type: 'number' },
1739
+ has_more: { type: 'boolean' },
1740
+ cursor: { type: 'string' },
1741
+ messages: {
1742
+ type: 'array',
1743
+ items: {
1744
+ type: 'object',
1745
+ additionalProperties: false,
1746
+ required: ['message_id', 'type', 'sender', 'text'],
1747
+ properties: {
1748
+ message_id: { type: 'string' },
1749
+ type: { type: 'string' },
1750
+ sender: { type: 'string', enum: ['user', 'bot', 'system'] },
1751
+ time: { type: 'string' },
1752
+ deleted: { type: 'boolean' },
1753
+ text: { type: 'string' },
1754
+ parent_message_id: { type: 'string' },
1755
+ resources: {
1756
+ type: 'array',
1757
+ items: {
1758
+ type: 'object',
1759
+ additionalProperties: false,
1760
+ required: ['type', 'file_key'],
1761
+ properties: {
1762
+ type: { type: 'string', enum: ['image', 'file'] },
1763
+ file_key: { type: 'string' },
1764
+ name: { type: 'string' },
1765
+ folder: { type: 'boolean' },
1766
+ },
1767
+ },
1768
+ },
1769
+ },
1770
+ },
1771
+ },
1772
+ code: { type: 'string' },
1773
+ reason: { type: 'string' },
1774
+ },
1775
+ }
1776
+
1777
+ // ---- Topic messages runtime deps ----------------------------------------
1778
+
1779
+ /**
1780
+ * Everything the topic-messages tool runtime needs, injected by the service.
1781
+ *
1782
+ * `noteKnownMessage` / `isKnownMessage` MUST be the attachment-fetch runtime's
1783
+ * methods: both tools share one per-route whitelist, so a message discovered
1784
+ * here can immediately be fetched by `feishu_attachment_fetch` (AC5), and a
1785
+ * main-timeline message the fetch/quote layers already admitted stays readable
1786
+ * from a topic route.
1787
+ */
1788
+ export interface TopicMessagesRuntimeDeps {
1789
+ /** Live plugin configuration (switch, timeout). */
1790
+ config(): FeishuConfig
1791
+ /** Live Feishu channel; undefined while disconnected/shut down. */
1792
+ channel(): FeishuChannel | undefined
1793
+ /** Resolve the route currently bound to one session. */
1794
+ routeOfSession(sessionId: string): AttachmentFetchRoute | undefined
1795
+ /** Register one discovered message's resources for the shared fetch whitelist. */
1796
+ noteKnownMessage(
1797
+ route: AttachmentFetchRoute,
1798
+ message: KnownAttachmentMessageInput,
1799
+ source: KnownAttachmentSource,
1800
+ ): void
1801
+ /** Whether one message id is already in the route's whitelist. */
1802
+ isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean
1803
+ /**
1804
+ * Whether `feishu_attachment_fetch` is usable in this deployment (native
1805
+ * backend + tool enabled). `false` makes the tool description tell the model
1806
+ * to report attachment metadata instead of fetching. Absent = available.
1807
+ */
1808
+ fetchAvailable?(): boolean
1809
+ /** Structured warning sink (mount degradation, unexpected failures). */
1810
+ warn(message: string, ...args: unknown[]): void
1811
+ }
1812
+
1813
+ type TopicReadOutcome =
1814
+ | { readonly ok: true; readonly value: TopicMessagesValue }
1815
+ | { readonly ok: false; readonly code: TopicMessagesFailureCode; readonly reason: string }
1816
+
1817
+ /**
1818
+ * The `feishu_topic_messages` runtime: mounts the tool into Agent scopes and
1819
+ * executes message reads against the session's current route.
1820
+ *
1821
+ * It deliberately owns no per-route state: the authorization whitelist lives in
1822
+ * the attachment-fetch runtime (shared through {@link TopicMessagesRuntimeDeps}),
1823
+ * so this runtime only needs its mount bookkeeping.
1824
+ */
1825
+ export class TopicMessagesToolRuntime {
1826
+ /** Agent scope → its one mount attempt (cordis callbacks get distinct ctx objects). */
1827
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
1828
+ private warnedMissingTools = false
1829
+ private warnedMountFailure = false
1830
+ private warnedUnavailable = false
1831
+
1832
+ constructor(private readonly deps: TopicMessagesRuntimeDeps) {}
1833
+
1834
+ /** Whether the feature switch currently allows tool registration/execution. */
1835
+ enabled(): boolean {
1836
+ return this.deps.config().feishuTools.topicMessages
1837
+ }
1838
+
1839
+ /**
1840
+ * Register the tool inside one Agent scope. Mirrors the attachment tool's
1841
+ * mount: `inject(['tools'])` is the P0-proven path, missing services are a
1842
+ * no-op with one readable warning, and a slow host never blocks session
1843
+ * creation (bounded 2 s wait, the pending injection can still backfill).
1844
+ */
1845
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
1846
+ if (!this.enabled()) return
1847
+ // Cordis hands the inject callback a DIFFERENT context object than the
1848
+ // agent ctx, so dedupe on the outer scope and reuse its single attempt.
1849
+ const existing = this.mountAttempts.get(agentCtx)
1850
+ if (existing !== undefined) return await existing
1851
+ const attempt = this.mountAttempt(agentCtx, sessionId)
1852
+ this.mountAttempts.set(agentCtx, attempt)
1853
+ await attempt
1854
+ }
1855
+
1856
+ /** No process-local state to drop; kept for symmetry with the fetch runtime. */
1857
+ clear(): void {
1858
+ // Intentionally empty: this runtime registers a tool per Agent scope only.
1859
+ }
1860
+
1861
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
1862
+ let injected = false
1863
+ const outcome = await waitForAvailability(
1864
+ Promise.resolve(agentCtx.inject(['tools'], (ctx) => {
1865
+ injected = true
1866
+ this.registerInScope(ctx, sessionId)
1867
+ })),
1868
+ // A deployment that already timed out once is unlikely to gain the
1869
+ // services a moment later: fail fast on later sessions while the pending
1870
+ // injection can still backfill registration if they do appear.
1871
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
1872
+ )
1873
+ if (outcome === 'failed') {
1874
+ this.warnMountFailure(`dsh-channels: mounting ${TOPIC_MESSAGES_TOOL_NAME} failed`)
1875
+ return
1876
+ }
1877
+ // Real cordis settles an injection whose dependencies never appear WITHOUT
1878
+ // calling the callback; treat "settled but never injected" as unavailable.
1879
+ // The fiber stays pending so a late service still backfills registration.
1880
+ if (!injected && !this.warnedUnavailable) {
1881
+ this.warnedUnavailable = true
1882
+ this.deps.warn(
1883
+ 'dsh-channels: %s 未挂载:宿主 tools 服务不可用(会话继续)',
1884
+ TOPIC_MESSAGES_TOOL_NAME,
1885
+ )
1886
+ }
1887
+ }
1888
+
1889
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
1890
+ // No per-callback-context dedupe: when a host service unloads/reloads,
1891
+ // cordis disposes the injection fiber's effects (including our
1892
+ // `tools.register`) and invokes this callback again with the same ctx
1893
+ // object. Skipping the re-registration would silently lose the tool.
1894
+ const tools = agentCtx.tools
1895
+ if (tools === undefined || typeof tools.register !== 'function') {
1896
+ if (!this.warnedMissingTools) {
1897
+ this.warnedMissingTools = true
1898
+ this.deps.warn(
1899
+ 'dsh-channels: %s 未注册:宿主 tools 服务不可用(话题消息工具已禁用)',
1900
+ TOPIC_MESSAGES_TOOL_NAME,
1901
+ )
1902
+ }
1903
+ return
1904
+ }
1905
+ try {
1906
+ tools.register(this.definitionFor(sessionId))
1907
+ } catch (error) {
1908
+ this.warnMountFailure(
1909
+ `dsh-channels: registering ${TOPIC_MESSAGES_TOOL_NAME} failed: ${describeFetchError(error)}`,
1910
+ )
1911
+ }
1912
+ }
1913
+
1914
+ private warnMountFailure(message: string): void {
1915
+ if (this.warnedMountFailure) return
1916
+ this.warnedMountFailure = true
1917
+ this.deps.warn(message)
1918
+ }
1919
+
1920
+ private definitionFor(sessionId: string): ToolDefinition {
1921
+ return {
1922
+ name: TOPIC_MESSAGES_TOOL_NAME,
1923
+ description: TOPIC_MESSAGES_DESCRIPTION
1924
+ + (this.isFetchUnavailable() ? TOPIC_MESSAGES_FETCH_UNAVAILABLE_DESCRIPTION : ''),
1925
+ parameters: TOPIC_MESSAGES_PARAMETERS,
1926
+ output: {
1927
+ schema: TOPIC_MESSAGES_OUTPUT_SCHEMA,
1928
+ render: (_args, value) => renderTopicMessagesResult(value),
1929
+ },
1930
+ execute: async (rawArgs, exec) => await this.execute(sessionId, rawArgs, exec),
1931
+ presentCall: args => presentTopicMessagesCall(args),
1932
+ presentResult: (_args, value) => presentTopicMessagesResult(value),
1933
+ isConcurrencySafe: () => true,
1934
+ }
1935
+ }
1936
+
1937
+ /**
1938
+ * Whether the deployment cannot fetch attachments (archive backend / fetch
1939
+ * tool disabled). A probe failure keeps the model guidance unchanged (the
1940
+ * tool still works; a fetch attempt returns its own readable failure).
1941
+ */
1942
+ private isFetchUnavailable(): boolean {
1943
+ try {
1944
+ return this.deps.fetchAvailable?.() === false
1945
+ } catch {
1946
+ return false
1947
+ }
1948
+ }
1949
+
1950
+ // ---- Execution --------------------------------------------------------
1951
+
1952
+ private async execute(
1953
+ sessionId: string,
1954
+ rawArgs: Record<string, unknown>,
1955
+ exec: ToolExecution,
1956
+ ): Promise<TopicMessagesValue> {
1957
+ const parsed = topicMessagesArgsSchema.safeParse(rawArgs)
1958
+ if (!parsed.success) {
1959
+ // A thrown, readable error is the correct shape for invalid arguments:
1960
+ // the host turns it into a failed tool call with a text message (never a
1961
+ // raw object rendered as `[object Object]`).
1962
+ const issue = parsed.error.issues[0]
1963
+ const path = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
1964
+ throw new Error(
1965
+ `${TOPIC_MESSAGES_TOOL_NAME}: invalid ${path}: ${issue?.message ?? 'invalid arguments'}`,
1966
+ )
1967
+ }
1968
+ const args = parsed.data
1969
+ if (args.message_id !== undefined && args.cursor !== undefined) {
1970
+ throw new Error(
1971
+ `${TOPIC_MESSAGES_TOOL_NAME}: invalid arguments: message_id 与 cursor 不能同时使用`
1972
+ + '(message_id 是单条读取,cursor 只用于列表翻页)',
1973
+ )
1974
+ }
1975
+ if (!this.enabled()) return topicFailureValue(args, undefined, 'TOOL_DISABLED')
1976
+
1977
+ // The route is re-read on every execution: a session can be re-bound (chat
1978
+ // route replaced by a topic route) between the mount and a later call.
1979
+ const route = this.deps.routeOfSession(sessionId)
1980
+ if (route === undefined) return topicFailureValue(args, undefined, 'ROUTE_NOT_FOUND')
1981
+
1982
+ const config = this.deps.config().feishuTools
1983
+ const reader = new MessageReader({ channel: () => this.deps.channel() })
1984
+ try {
1985
+ const outcome = args.message_id === undefined
1986
+ ? await this.listMessages(reader, route, args, exec, config.topicMessagesTimeoutMs)
1987
+ : await this.readMessageById(reader, route, args.message_id, exec, config.topicMessagesTimeoutMs)
1988
+ if (outcome.ok) return outcome.value
1989
+ return topicFailureValue(args, route, outcome.code, outcome.reason)
1990
+ } catch (error) {
1991
+ if (exec.signal.aborted) return topicFailureValue(args, route, 'ABORTED')
1992
+ const detail = describeReadError(error)
1993
+ this.deps.warn(
1994
+ 'dsh-channels: %s failed unexpectedly: %s',
1995
+ TOPIC_MESSAGES_TOOL_NAME,
1996
+ detail,
1997
+ )
1998
+ return topicFailureValue(
1999
+ args,
2000
+ route,
2001
+ 'INTERNAL_ERROR',
2002
+ `${TOPIC_MESSAGES_FAILURE_REASONS.INTERNAL_ERROR}:${detail}`,
2003
+ )
2004
+ }
2005
+ }
2006
+
2007
+ /** `message.list` mode: newest-first page of the current route's container. */
2008
+ private async listMessages(
2009
+ reader: MessageReader,
2010
+ route: AttachmentFetchRoute,
2011
+ args: TopicMessagesArgs,
2012
+ exec: ToolExecution,
2013
+ timeoutMs: number,
2014
+ ): Promise<TopicReadOutcome> {
2015
+ const limit = args.limit ?? TOPIC_MESSAGES_DEFAULT_LIMIT
2016
+ const listed = await reader.listRoute(route, {
2017
+ pageSize: limit,
2018
+ ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
2019
+ order: 'newest-first',
2020
+ signal: exec.signal,
2021
+ timeoutMs,
2022
+ })
2023
+ if (!listed.ok) {
2024
+ return { ok: false, code: topicFailureCodeOf(listed.code), reason: listed.reason }
2025
+ }
2026
+ const page = listed.value
2027
+ const messages: TopicMessageView[] = []
2028
+ for (const item of page.items) {
2029
+ messages.push(topicMessageViewOf(item))
2030
+ // Registering every row of a long history would churn the shared LRU
2031
+ // whitelist (a couple of 50-row pages evict the trigger/quote attachments
2032
+ // that ARE fetchable). Plain-text rows carry no downloadable resource, so
2033
+ // only attachment rows and merged-forward containers are noted.
2034
+ if (item.resources.length === 0 && item.msgType !== 'merge_forward') continue
2035
+ const known = knownMessageOfItems([item])
2036
+ if (known !== undefined) this.noteKnown(route, known)
2037
+ }
2038
+ const container = topicContainerOf(route)
2039
+ const label = topicContainerLabel(container.type)
2040
+ const more = page.hasMore ? ',还有更多(用 cursor 翻页)' : ''
2041
+ const summary = messages.length === 0
2042
+ ? `没有可读取的消息(${label} ${displayText(container.id)})`
2043
+ : `已读取 ${messages.length} 条消息${more}(${label} ${displayText(container.id)},按时间倒序)`
2044
+ return {
2045
+ ok: true,
2046
+ value: {
2047
+ ok: true,
2048
+ summary,
2049
+ container,
2050
+ order: 'newest_first',
2051
+ message_count: messages.length,
2052
+ has_more: page.hasMore,
2053
+ ...(page.pageToken === undefined ? {} : { cursor: page.pageToken }),
2054
+ messages,
2055
+ },
2056
+ }
2057
+ }
2058
+
2059
+ /** `message.get` mode: one message, a merged-forward container expanded. */
2060
+ private async readMessageById(
2061
+ reader: MessageReader,
2062
+ route: AttachmentFetchRoute,
2063
+ messageId: string,
2064
+ exec: ToolExecution,
2065
+ timeoutMs: number,
2066
+ ): Promise<TopicReadOutcome> {
2067
+ const read = await reader.readMessage(messageId, { signal: exec.signal, timeoutMs })
2068
+ if (!read.ok) {
2069
+ return { ok: false, code: topicFailureCodeOf(read.code), reason: read.reason }
2070
+ }
2071
+ const items = read.value
2072
+ // The container is NOT necessarily the first item: a merged-forward
2073
+ // `message.get` returns a flat list whose children carry `upper_message_id`,
2074
+ // so the shared resolver picks the top-level item independent of order.
2075
+ const container = quoteItemsOf(items)
2076
+ if (container === undefined) {
2077
+ return {
2078
+ ok: false,
2079
+ code: 'MESSAGE_READ_FAILED',
2080
+ reason: `飞书未返回该消息(${displayText(messageId)}),可能已被撤回或无权访问`,
2081
+ }
2082
+ }
2083
+ // Route membership is enforced on the container: another chat, another
2084
+ // topic of the same group, or an unregistered main-timeline message is
2085
+ // refused. A message already admitted to this route (trigger/quote/recent)
2086
+ // may be read even without a topic id (it precedes the topic).
2087
+ const known = this.deps.isKnownMessage(route, messageId)
2088
+ if (!messageBelongsToRoute(route, container, known ? [messageId] : [])) {
2089
+ return {
2090
+ ok: false,
2091
+ code: 'MESSAGE_OUT_OF_ROUTE',
2092
+ reason: TOPIC_MESSAGES_FAILURE_REASONS.MESSAGE_OUT_OF_ROUTE,
2093
+ }
2094
+ }
2095
+ // The read succeeded: register the container (merged-forward resources are
2096
+ // downloadable only through the top-level container id) so the fetch tool
2097
+ // can serve them without a second discovery round-trip.
2098
+ const knownMessage = knownMessageOfItems(items)
2099
+ if (knownMessage !== undefined) this.noteKnown(route, knownMessage)
2100
+
2101
+ // Container first, then its children in the order Feishu returned them.
2102
+ // The value is bounded by the same cap as rendering: one huge merged
2103
+ // forward must not flood the tool result/session history.
2104
+ const children = items.filter(item => item !== container)
2105
+ const ordered = [container, ...children]
2106
+ const shown = ordered.slice(0, TOPIC_MESSAGES_RENDER_MAX_MESSAGES)
2107
+ const hidden = ordered.length - shown.length
2108
+ const messages = shown.map(item => topicMessageViewOf(item))
2109
+ const resourceCount = ordered.reduce((total, item) => total + item.resources.length, 0)
2110
+ const containerInfo = topicContainerOf(route)
2111
+ const label = topicContainerLabel(containerInfo.type)
2112
+ const childCount = container.msgType === 'merge_forward' ? children.length : 0
2113
+ const mergeNote = container.msgType === 'merge_forward'
2114
+ ? `(合并转发,已展开 ${childCount} 条子消息)`
2115
+ : ''
2116
+ const resourceNote = resourceCount > 0 ? `,含 ${resourceCount} 个附件` : ''
2117
+ const hiddenNote = hidden > 0 ? `(另有 ${hidden} 条消息未展示)` : ''
2118
+ return {
2119
+ ok: true,
2120
+ value: {
2121
+ ok: true,
2122
+ summary: `已读取消息 ${displayText(container.messageId)}${mergeNote}${resourceNote}${hiddenNote}`
2123
+ + `(${label} ${displayText(containerInfo.id)})`,
2124
+ container: containerInfo,
2125
+ order: 'single',
2126
+ message_count: messages.length,
2127
+ has_more: false,
2128
+ messages,
2129
+ },
2130
+ }
2131
+ }
2132
+
2133
+ /**
2134
+ * Record one discovered message in the shared fetch whitelist. Bookkeeping
2135
+ * failures are warnings only: the model already has the read result and the
2136
+ * turn must not fail because the whitelist is momentarily unavailable.
2137
+ */
2138
+ private noteKnown(route: AttachmentFetchRoute, message: KnownAttachmentMessageInput): void {
2139
+ try {
2140
+ this.deps.noteKnownMessage(route, message, 'tool-result')
2141
+ } catch (error) {
2142
+ this.deps.warn(
2143
+ 'dsh-channels: %s failed to register a known message: %s',
2144
+ TOPIC_MESSAGES_TOOL_NAME,
2145
+ describeReadError(error),
2146
+ )
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ /** The route's read container: the thread id for a topic, the chat id otherwise. */
2152
+ function topicContainerOf(route: AttachmentFetchRoute | undefined): TopicMessagesContainer {
2153
+ if (route === undefined) {
2154
+ // No route means no read scope; the empty id keeps the value shape uniform
2155
+ // for replay/rendering without inventing an authorization fact.
2156
+ return { type: 'chat', id: '' }
2157
+ }
2158
+ return route.kind === 'thread'
2159
+ ? { type: 'thread', id: route.key }
2160
+ : { type: 'chat', id: route.chatId }
2161
+ }
2162
+
2163
+ function topicContainerLabel(type: TopicMessagesContainer['type']): string {
2164
+ return type === 'thread' ? '话题' : '聊天'
2165
+ }
2166
+
2167
+ /** Map one `message-read` failure code into the tool's stable code set. */
2168
+ function topicFailureCodeOf(code: MessageReadFailureCode): TopicMessagesFailureCode {
2169
+ switch (code) {
2170
+ case 'PERMISSION_DENIED':
2171
+ return 'PERMISSION_DENIED'
2172
+ case 'INVALID_ARGUMENT':
2173
+ return 'INVALID_ARGUMENT'
2174
+ case 'ABORTED':
2175
+ return 'ABORTED'
2176
+ case 'TIMEOUT':
2177
+ return 'TIMEOUT'
2178
+ case 'CHANNEL_UNAVAILABLE':
2179
+ case 'MESSAGE_READ_FAILED':
2180
+ default:
2181
+ return 'MESSAGE_READ_FAILED'
2182
+ }
2183
+ }
2184
+
2185
+ /** One structured failure value (never thrown as a business error). */
2186
+ function topicFailureValue(
2187
+ args: TopicMessagesArgs | undefined,
2188
+ route: AttachmentFetchRoute | undefined,
2189
+ code: TopicMessagesFailureCode,
2190
+ reasonOverride?: string,
2191
+ ): TopicMessagesValue {
2192
+ const reason = reasonOverride ?? TOPIC_MESSAGES_FAILURE_REASONS[code]
2193
+ const container = topicContainerOf(route)
2194
+ const where = container.id === ''
2195
+ ? ''
2196
+ : `(${topicContainerLabel(container.type)} ${displayText(container.id)})`
2197
+ return {
2198
+ ok: false,
2199
+ summary: `${reason}${where}`,
2200
+ container,
2201
+ order: args?.message_id === undefined ? 'newest_first' : 'single',
2202
+ message_count: 0,
2203
+ has_more: false,
2204
+ messages: [],
2205
+ code,
2206
+ reason,
2207
+ }
2208
+ }
2209
+
2210
+ // ---- Topic messages rendering -------------------------------------------
2211
+
2212
+ /** Rendered message lines are bounded (whole-block character guard below). */
2213
+ const TOPIC_MESSAGES_RENDER_MAX_MESSAGES = TOPIC_MESSAGES_MAX_LIMIT
2214
+ const TOPIC_MESSAGES_RENDER_MAX_CHARS = 12_000
2215
+
2216
+ /** One model-facing line for a message view (id first so it is fetchable). */
2217
+ function topicMessageLine(view: TopicMessageView): string {
2218
+ const messageId = typeof view.message_id === 'string' ? displayText(view.message_id) : ''
2219
+ const sender = view.sender === 'bot' ? '机器人' : view.sender === 'system' ? '系统' : '用户'
2220
+ const time = typeof view.time === 'string' && view.time !== '' ? `[${view.time}] ` : ''
2221
+ const text = typeof view.text === 'string' ? view.text : ''
2222
+ const resources = Array.isArray(view.resources) ? view.resources : []
2223
+ const idPart = messageId === '' ? '' : `${messageId} `
2224
+ return `- ${idPart}${time}${sender}: ${text}${topicResourcesSuffix(resources)}`
2225
+ }
2226
+
2227
+ /** Compact attachment suffix of one rendered line (mirrors the prompt blocks). */
2228
+ function topicResourcesSuffix(resources: readonly TopicMessageResourceView[]): string {
2229
+ if (resources.length === 0) return ''
2230
+ const images = resources.filter(resource => resource.type === 'image').length
2231
+ const files = resources.filter(resource => resource.type === 'file')
2232
+ const parts: string[] = []
2233
+ if (images > 0) parts.push(`图片×${images}`)
2234
+ if (files.length > 0) {
2235
+ const named = files
2236
+ .filter(resource => typeof resource.name === 'string' && resource.name !== '')
2237
+ .slice(0, 2)
2238
+ .map(resource => `"${displayText(resource.name ?? '')}"`)
2239
+ parts.push(named.length === 0 ? `文件×${files.length}` : `文件 ${named.join('、')}`)
2240
+ }
2241
+ return `(附件:${parts.join(';')})`
2242
+ }
2243
+
2244
+ /**
2245
+ * Turn one tool result into model content: the summary plus one bounded line
2246
+ * per message. Rendering never throws on a replayed/malformed value (session
2247
+ * history projection) and the whole text block is length-bounded.
2248
+ */
2249
+ export function renderTopicMessagesResult(value: unknown): readonly ToolContentBlock[] {
2250
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<TopicMessagesValue>
2251
+ const summary = typeof result.summary === 'string' && result.summary !== ''
2252
+ ? result.summary
2253
+ : TOPIC_MESSAGES_TOOL_NAME
2254
+ const lines: string[] = [summary]
2255
+ if (typeof result.code === 'string') lines.push(`错误码:${result.code}`)
2256
+ if (typeof result.reason === 'string' && result.reason !== '' && result.reason !== summary) {
2257
+ lines.push(`原因:${result.reason}`)
2258
+ }
2259
+ // The model needs the opaque token verbatim to continue paging; it is only
2260
+ // visible through this rendered text (the host shows render output, not the
2261
+ // structured value). Omitting it made the model guess message ids in the
2262
+ // 2026-10-21 real-device smoke test.
2263
+ if (typeof result.cursor === 'string' && result.cursor !== '') {
2264
+ lines.push(`- 下一页 cursor: ${sanitizeInlineText(result.cursor, 512)}`)
2265
+ }
2266
+ const messages = (Array.isArray(result.messages) ? result.messages : [])
2267
+ .filter((message): message is TopicMessageView => typeof message === 'object' && message !== null)
2268
+ let used = lines.join('\n').length
2269
+ let shown = 0
2270
+ for (const view of messages) {
2271
+ if (shown >= TOPIC_MESSAGES_RENDER_MAX_MESSAGES) break
2272
+ const line = topicMessageLine(view)
2273
+ // Keep room for the truncation marker so the whole block stays bounded.
2274
+ if (used + line.length + 1 > TOPIC_MESSAGES_RENDER_MAX_CHARS - 120) break
2275
+ lines.push(line)
2276
+ used += line.length + 1
2277
+ shown += 1
2278
+ }
2279
+ const hidden = messages.length - shown
2280
+ if (hidden > 0) {
2281
+ lines.push(`- …(其余 ${hidden} 条消息未展示;可用 message_id 或 limit/cursor 继续读取)`)
2282
+ }
2283
+ return [{ type: 'text', text: lines.join('\n') }]
2284
+ }
2285
+
2286
+ /** Host-local card call projection: "read one message" / "list recent messages". */
2287
+ export function presentTopicMessagesCall(args: Record<string, unknown>): string {
2288
+ const messageId = typeof args.message_id === 'string' && args.message_id !== ''
2289
+ ? args.message_id
2290
+ : undefined
2291
+ if (messageId !== undefined) return `读取飞书消息 ${displayText(messageId)}`
2292
+ const rawLimit = args.limit
2293
+ const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit)
2294
+ ? clampInt(rawLimit, 1, TOPIC_MESSAGES_MAX_LIMIT)
2295
+ : TOPIC_MESSAGES_DEFAULT_LIMIT
2296
+ return typeof args.cursor === 'string' && args.cursor !== ''
2297
+ ? `读取飞书话题消息(最近 ${limit} 条,翻页)`
2298
+ : `读取飞书话题消息(最近 ${limit} 条)`
2299
+ }
2300
+
2301
+ /** Host-local card result projection: the one-line outcome. */
2302
+ export function presentTopicMessagesResult(value: unknown): string {
2303
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<TopicMessagesValue>
2304
+ return typeof result.summary === 'string' ? result.summary : TOPIC_MESSAGES_TOOL_NAME
2305
+ }