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