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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Model-facing `feishu_send_file`: sends one image or file the Agent produced
3
+ * back to the conversation the Session is bound to.
4
+ *
5
+ * Path policy: the model names a path; only a regular file inside the topic's
6
+ * working directory (`RouteRecord.sessionDir`, or `workspacePath` for records
7
+ * written before per-topic directories) is sent. Both the root and the target
8
+ * are resolved with `realpath`, so `..` segments and symlinks cannot reach
9
+ * another topic's directory or the rest of the filesystem. The bytes are read
10
+ * through one file handle whose device/inode must match the contained path
11
+ * re-resolved after opening, so swapping a directory for a symlink between the
12
+ * check and the read is detected.
13
+ *
14
+ * Delivery: the message replies to the route's latest card. A topic route
15
+ * replies inside its topic; a chat route replies on the main timeline. The
16
+ * tool never creates a topic and never targets another chat: a recalled or
17
+ * deleted reply target fails the call instead of falling back to a fresh
18
+ * chat message.
19
+ *
20
+ * Feishu limits (`im.v1.image.create` / `im.v1.file.create`): images up to
21
+ * 10 MB in JPEG/PNG/WEBP/GIF/TIFF/BMP/ICO, files up to 30 MB, zero-byte
22
+ * uploads rejected. Both uploads need the `im:resource` (or
23
+ * `im:resource:upload`) scope.
24
+ *
25
+ * @module dsh-channels/feishu/send-file
26
+ */
27
+
28
+ import { constants } from 'node:fs'
29
+ import { open, realpath, stat } from 'node:fs/promises'
30
+ import path from 'node:path'
31
+ import { z } from 'zod'
32
+ import type { FeishuConfig } from '../config.ts'
33
+ import type {
34
+ AgentContext, ToolContentBlock, ToolDefinition, ToolExecution,
35
+ } from '../host/agent.ts'
36
+ import type { FeishuChannel, OutboundMedia } from '../feishu-channel.ts'
37
+ import { FeishuApiError, sniffImageMediaType } from '../feishu-channel.ts'
38
+ import {
39
+ MOUNT_INJECT_RETRY_WAIT_MS, MOUNT_INJECT_WAIT_MS, waitForAvailability,
40
+ } from './tools.ts'
41
+
42
+ /** Model-facing tool name; stable across releases (session history references it). */
43
+ export const SEND_FILE_TOOL_NAME = 'feishu_send_file'
44
+
45
+ /** Feishu `im.v1.image.create` size limit. */
46
+ export const FEISHU_IMAGE_MAX_BYTES = 10 * 1024 * 1024
47
+
48
+ /** Feishu `im.v1.file.create` size limit. */
49
+ export const FEISHU_FILE_MAX_BYTES = 30 * 1024 * 1024
50
+
51
+ /** Longest file name passed to Feishu (characters). */
52
+ const MAX_FILE_NAME_CHARS = 200
53
+
54
+ const SEND_FILE_DESCRIPTION =
55
+ '把当前话题工作目录中的一个图片或文件发送给飞书里的用户(回复在当前话题/会话中)。'
56
+ + '当你生成了用户需要直接查看或下载的图片、报告、表格、压缩包等文件时使用。'
57
+ + 'path 可以是绝对路径,或相对当前话题工作目录的路径;只能发送该目录内的普通文件(不能发送目录,也不能通过 .. 或符号链接访问目录外的文件),'
58
+ + '目录外的文件请先复制到工作目录。'
59
+ + 'kind 默认 auto:可识别的图片(JPEG/PNG/WEBP/GIF/TIFF/BMP/ICO,≤10 MB)以图片消息发送,其余以文件消息发送;'
60
+ + '文件上限 30 MB,空文件会被拒绝。一次调用发送一个文件;需要发送多个时逐个调用。'
61
+
62
+ /** Tool argument validation (the model may send anything). */
63
+ export const sendFileArgsSchema = z.object({
64
+ path: z.string().trim().min(1).max(4096),
65
+ kind: z.enum(['auto', 'image', 'file']).optional(),
66
+ file_name: z.string().trim().min(1).max(MAX_FILE_NAME_CHARS).optional(),
67
+ }).strict()
68
+
69
+ export type SendFileArgs = z.infer<typeof sendFileArgsSchema>
70
+
71
+ /** JSON Schema advertised to the model. */
72
+ export const SEND_FILE_PARAMETERS: Record<string, unknown> = {
73
+ type: 'object',
74
+ properties: {
75
+ path: {
76
+ type: 'string',
77
+ description: '要发送的文件路径:绝对路径,或相对当前话题工作目录的路径。',
78
+ },
79
+ kind: {
80
+ type: 'string',
81
+ enum: ['auto', 'image', 'file'],
82
+ description: 'auto(默认)按内容判断;image 强制以图片发送(非图片或超过 10 MB 会失败);file 一律以文件附件发送。',
83
+ },
84
+ file_name: {
85
+ type: 'string',
86
+ description: '文件消息展示给用户的文件名,默认取路径的文件名;图片消息忽略此项。',
87
+ },
88
+ },
89
+ required: ['path'],
90
+ additionalProperties: false,
91
+ }
92
+
93
+ /** Stable failure codes; the readable `reason` carries the details. */
94
+ export type SendFileFailureCode =
95
+ | 'TOOL_DISABLED'
96
+ | 'ROUTE_NOT_FOUND'
97
+ | 'CHANNEL_UNAVAILABLE'
98
+ | 'PATH_OUTSIDE_WORKSPACE'
99
+ | 'FILE_NOT_FOUND'
100
+ | 'NOT_A_FILE'
101
+ | 'EMPTY_FILE'
102
+ | 'FILE_TOO_LARGE'
103
+ | 'NOT_AN_IMAGE'
104
+ | 'PERMISSION_DENIED'
105
+ | 'REPLY_TARGET_GONE'
106
+ | 'SEND_FAILED'
107
+ | 'ABORTED'
108
+
109
+ const FAILURE_REASONS: Readonly<Record<SendFileFailureCode, string>> = {
110
+ TOOL_DISABLED: 'feishu_send_file 已在插件配置中关闭',
111
+ ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题或会话,无法确定发送目标',
112
+ CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
113
+ PATH_OUTSIDE_WORKSPACE: '只能发送当前话题工作目录内的文件;请先把文件复制到工作目录再发送',
114
+ FILE_NOT_FOUND: '文件不存在或不可读',
115
+ NOT_A_FILE: '该路径不是普通文件(目录等不能发送)',
116
+ EMPTY_FILE: '文件为空,飞书不接受 0 字节的上传',
117
+ FILE_TOO_LARGE: '文件超过飞书上传上限',
118
+ NOT_AN_IMAGE: '文件内容不是飞书支持的图片格式(JPEG/PNG/WEBP/GIF/TIFF/BMP/ICO);可用 kind=file 以文件发送',
119
+ PERMISSION_DENIED: '飞书拒绝上传:应用缺少 im:resource(获取与上传图片或文件资源)权限,请管理员在开发者后台开通并发布版本',
120
+ REPLY_TARGET_GONE: '当前话题用于回复的卡片已被撤回或删除;为避免把文件发到群主时间线,已取消发送。请用户在话题里再发一条消息后重试',
121
+ SEND_FAILED: '飞书上传或发送失败',
122
+ ABORTED: '已取消;如果上传已经开始,消息仍可能送达',
123
+ }
124
+
125
+ /** Successful or failed tool value (lossless JSON). */
126
+ export type SendFileValue =
127
+ | {
128
+ readonly ok: true
129
+ readonly summary: string
130
+ readonly kind: 'image' | 'file'
131
+ readonly file_name: string
132
+ readonly bytes: number
133
+ readonly message_id: string
134
+ }
135
+ | {
136
+ readonly ok: false
137
+ readonly code: SendFileFailureCode
138
+ readonly reason: string
139
+ }
140
+
141
+ /** Output schema of {@link SendFileValue}. */
142
+ export const SEND_FILE_OUTPUT_SCHEMA: Record<string, unknown> = {
143
+ type: 'object',
144
+ properties: {
145
+ ok: { type: 'boolean' },
146
+ summary: { type: 'string' },
147
+ kind: { type: 'string', enum: ['image', 'file'] },
148
+ file_name: { type: 'string' },
149
+ bytes: { type: 'integer' },
150
+ message_id: { type: 'string' },
151
+ code: { type: 'string' },
152
+ reason: { type: 'string' },
153
+ },
154
+ required: ['ok'],
155
+ }
156
+
157
+ /** Where one Session's outbound files go and where they may come from. */
158
+ export interface SendFileTarget {
159
+ /** Message the upload replies to (the route's latest card). */
160
+ readonly replyTo: string
161
+ /** Whether the reply stays inside a topic (`kind: 'thread'` route). */
162
+ readonly inThread: boolean
163
+ /** Topic working directory; the only directory files may be sent from. */
164
+ readonly rootDir: string
165
+ }
166
+
167
+ export interface SendFileRuntimeDeps {
168
+ /** Live plugin configuration (switch). */
169
+ config(): FeishuConfig
170
+ /** Live Feishu channel; undefined while disconnected/shut down. */
171
+ channel(): FeishuChannel | undefined
172
+ /** Resolve the delivery target bound to one session, re-read per call. */
173
+ targetOfSession(sessionId: string): SendFileTarget | undefined
174
+ /** Structured warning sink (mount degradation, unexpected failures). */
175
+ warn(message: string, ...args: unknown[]): void
176
+ }
177
+
178
+ /**
179
+ * The `feishu_send_file` runtime: mounts the tool into Agent scopes and sends
180
+ * one workspace file per call to the session's current route.
181
+ */
182
+ export class SendFileToolRuntime {
183
+ /** Agent scope → its one mount attempt (cordis callbacks get distinct ctx objects). */
184
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
185
+ private warnedMissingTools = false
186
+ private warnedMountFailure = false
187
+ private warnedUnavailable = false
188
+
189
+ constructor(private readonly deps: SendFileRuntimeDeps) {}
190
+
191
+ /** Whether the feature switch currently allows tool registration/execution. */
192
+ enabled(): boolean {
193
+ return this.deps.config().feishuTools.sendFile
194
+ }
195
+
196
+ /**
197
+ * Register the tool inside one Agent scope. Same mount policy as the other
198
+ * channel tools: missing `tools` service is a no-op with one warning, and a
199
+ * slow host never blocks session creation.
200
+ *
201
+ * @param agentCtx Agent scope to register in.
202
+ * @param sessionId Session the tool sends for.
203
+ */
204
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
205
+ if (!this.enabled()) return
206
+ const existing = this.mountAttempts.get(agentCtx)
207
+ if (existing !== undefined) return await existing
208
+ const attempt = this.mountAttempt(agentCtx, sessionId)
209
+ this.mountAttempts.set(agentCtx, attempt)
210
+ await attempt
211
+ }
212
+
213
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
214
+ let injected = false
215
+ const outcome = await waitForAvailability(
216
+ Promise.resolve(agentCtx.inject(['tools'], (ctx) => {
217
+ injected = true
218
+ this.registerInScope(ctx, sessionId)
219
+ })),
220
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
221
+ )
222
+ if (outcome === 'failed') {
223
+ this.warnMountFailure(`dsh-channels: mounting ${SEND_FILE_TOOL_NAME} failed`)
224
+ return
225
+ }
226
+ if (!injected && !this.warnedUnavailable) {
227
+ this.warnedUnavailable = true
228
+ this.deps.warn('dsh-channels: %s 未挂载:宿主 tools 服务不可用(会话继续)', SEND_FILE_TOOL_NAME)
229
+ }
230
+ }
231
+
232
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
233
+ const tools = agentCtx.tools
234
+ if (tools === undefined || typeof tools.register !== 'function') {
235
+ if (!this.warnedMissingTools) {
236
+ this.warnedMissingTools = true
237
+ this.deps.warn('dsh-channels: %s 未注册:宿主 tools 服务不可用(发送文件工具已禁用)', SEND_FILE_TOOL_NAME)
238
+ }
239
+ return
240
+ }
241
+ try {
242
+ tools.register(this.definitionFor(sessionId))
243
+ } catch (error) {
244
+ this.warnMountFailure(`dsh-channels: registering ${SEND_FILE_TOOL_NAME} failed: ${describeError(error)}`)
245
+ }
246
+ }
247
+
248
+ private warnMountFailure(message: string): void {
249
+ if (this.warnedMountFailure) return
250
+ this.warnedMountFailure = true
251
+ this.deps.warn(message)
252
+ }
253
+
254
+ private definitionFor(sessionId: string): ToolDefinition {
255
+ return {
256
+ name: SEND_FILE_TOOL_NAME,
257
+ description: SEND_FILE_DESCRIPTION,
258
+ parameters: SEND_FILE_PARAMETERS,
259
+ output: {
260
+ schema: SEND_FILE_OUTPUT_SCHEMA,
261
+ render: (_args, value) => renderSendFileResult(value),
262
+ },
263
+ execute: async (rawArgs, exec) => await this.execute(sessionId, rawArgs, exec),
264
+ presentCall: args => presentSendFileCall(args),
265
+ presentResult: (_args, value) => presentSendFileResult(value),
266
+ // Two sends race for message order in the chat.
267
+ isConcurrencySafe: () => false,
268
+ }
269
+ }
270
+
271
+ private async execute(
272
+ sessionId: string,
273
+ rawArgs: Record<string, unknown>,
274
+ exec: ToolExecution,
275
+ ): Promise<SendFileValue> {
276
+ const parsed = sendFileArgsSchema.safeParse(rawArgs)
277
+ if (!parsed.success) {
278
+ const issue = parsed.error.issues[0]
279
+ const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
280
+ throw new Error(`${SEND_FILE_TOOL_NAME}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
281
+ }
282
+ const args = parsed.data
283
+ if (!this.enabled()) return failure('TOOL_DISABLED')
284
+ const target = this.deps.targetOfSession(sessionId)
285
+ if (target === undefined) return failure('ROUTE_NOT_FOUND')
286
+
287
+ const loaded = await loadWorkspaceFile(target.rootDir, args)
288
+ if (!loaded.ok) return loaded
289
+ if (exec.signal.aborted) return failure('ABORTED')
290
+ const channel = this.deps.channel()
291
+ if (channel === undefined) return failure('CHANNEL_UNAVAILABLE')
292
+
293
+ const { kind, fileName, data } = loaded
294
+ const media: OutboundMedia = kind === 'image' ? { kind, data } : { kind, data, fileName }
295
+ let sent: Promise<{ messageId: string }> | undefined
296
+ try {
297
+ sent = channel.replyMedia(target.replyTo, media, { replyInThread: target.inThread })
298
+ const result = await abortable(sent, exec.signal)
299
+ const label = kind === 'image' ? '图片' : '文件'
300
+ return {
301
+ ok: true,
302
+ summary: `已发送${label} ${displayText(fileName)}(${formatBytes(data.length)})`,
303
+ kind,
304
+ file_name: fileName,
305
+ bytes: data.length,
306
+ message_id: result.messageId,
307
+ }
308
+ } catch (error) {
309
+ if (exec.signal.aborted) {
310
+ // The send keeps running; only its late failure is still worth a log line.
311
+ sent?.catch(late => this.deps.warn('dsh-channels: %s failed after abort: %s', SEND_FILE_TOOL_NAME, describeError(late)))
312
+ return failure('ABORTED')
313
+ }
314
+ const detail = describeError(error)
315
+ if (isPermissionError(error)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
316
+ if (error instanceof FeishuApiError && error.code !== undefined && REPLY_TARGET_GONE_CODES.has(error.code)) {
317
+ return failure('REPLY_TARGET_GONE')
318
+ }
319
+ this.deps.warn('dsh-channels: %s failed: %s', SEND_FILE_TOOL_NAME, detail)
320
+ return failure('SEND_FAILED', `${FAILURE_REASONS.SEND_FAILED}:${detail}`)
321
+ }
322
+ }
323
+ }
324
+
325
+ // ---- File loading -------------------------------------------------------
326
+
327
+ type LoadedFile =
328
+ | { readonly ok: true; readonly kind: 'image' | 'file'; readonly fileName: string; readonly data: Buffer }
329
+ | Extract<SendFileValue, { ok: false }>
330
+
331
+ /**
332
+ * Resolve, contain, size-check and read one file under `rootDir`.
333
+ *
334
+ * @param rootDir Topic working directory.
335
+ * @param args Validated tool arguments.
336
+ * @returns The bytes and the message kind, or a failure value.
337
+ */
338
+ export async function loadWorkspaceFile(rootDir: string, args: SendFileArgs): Promise<LoadedFile> {
339
+ let root: string
340
+ try {
341
+ root = await realpath(rootDir)
342
+ } catch (error) {
343
+ return failure('FILE_NOT_FOUND', `当前话题工作目录不可用:${describeError(error)}`)
344
+ }
345
+ let real: string
346
+ try {
347
+ real = await realpath(path.resolve(root, args.path))
348
+ } catch {
349
+ // A missing path outside the root must not reveal anything beyond "not allowed".
350
+ return isInside(root, path.resolve(root, args.path))
351
+ ? failure('FILE_NOT_FOUND')
352
+ : failure('PATH_OUTSIDE_WORKSPACE')
353
+ }
354
+ if (!isInside(root, real)) return failure('PATH_OUTSIDE_WORKSPACE')
355
+
356
+ const requested = args.kind ?? 'auto'
357
+ const ceiling = requested === 'image' ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES
358
+ let handle
359
+ try {
360
+ handle = await open(real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
361
+ } catch (error) {
362
+ return failure('FILE_NOT_FOUND', `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`)
363
+ }
364
+ let data: Buffer
365
+ try {
366
+ const info = await handle.stat()
367
+ if (!info.isFile()) return failure('NOT_A_FILE')
368
+ // A directory swapped for a symlink after the realpath check would open a
369
+ // different inode than the contained path resolves to now.
370
+ const recheck = await realpath(real).then(again => (isInside(root, again) ? stat(again) : undefined), () => undefined)
371
+ if (recheck === undefined || recheck.dev !== info.dev || recheck.ino !== info.ino) {
372
+ return failure('PATH_OUTSIDE_WORKSPACE')
373
+ }
374
+ if (info.size === 0) return failure('EMPTY_FILE')
375
+ if (info.size > ceiling) return tooLarge(requested === 'image' ? 'image' : 'file', info.size)
376
+ data = await handle.readFile()
377
+ } catch (error) {
378
+ return failure('FILE_NOT_FOUND', `${FAILURE_REASONS.FILE_NOT_FOUND}:${describeError(error)}`)
379
+ } finally {
380
+ await handle.close().catch(() => undefined)
381
+ }
382
+ // The file may have grown or shrunk between stat and read.
383
+ if (data.length === 0) return failure('EMPTY_FILE')
384
+ if (data.length > ceiling) return tooLarge(requested === 'image' ? 'image' : 'file', data.length)
385
+
386
+ const isImage = isFeishuImage(data)
387
+ if (requested === 'image' && !isImage) return failure('NOT_AN_IMAGE')
388
+ const kind = requested === 'file' ? 'file'
389
+ : requested === 'image' ? 'image'
390
+ : isImage && data.length <= FEISHU_IMAGE_MAX_BYTES ? 'image' : 'file'
391
+ const fileName = sanitizeFileName(args.file_name ?? path.basename(real))
392
+ return { ok: true, kind, fileName, data }
393
+ }
394
+
395
+ /** Whether `candidate` is `root` itself or below it. */
396
+ function isInside(root: string, candidate: string): boolean {
397
+ const relative = path.relative(root, candidate)
398
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
399
+ }
400
+
401
+ /** Image formats Feishu accepts for `image_type: message`, by magic bytes. */
402
+ function isFeishuImage(data: Uint8Array): boolean {
403
+ if (sniffImageMediaType(data) !== undefined) return true
404
+ if (data.length >= 2 && data[0] === 0x42 && data[1] === 0x4D) return true // BMP
405
+ if (data.length >= 4 && data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2A && data[3] === 0x00) return true // TIFF LE
406
+ if (data.length >= 4 && data[0] === 0x4D && data[1] === 0x4D && data[2] === 0x00 && data[3] === 0x2A) return true // TIFF BE
407
+ return data.length >= 4 && data[0] === 0x00 && data[1] === 0x00 && data[2] === 0x01 && data[3] === 0x00 // ICO
408
+ }
409
+
410
+ /** File name shown in Feishu: no separators or control characters, bounded. */
411
+ function sanitizeFileName(value: string): string {
412
+ const cleaned = value.replace(/[\u0000-\u001f\u007f/\\]+/gu, '_').trim()
413
+ if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file'
414
+ return cleaned.length <= MAX_FILE_NAME_CHARS ? cleaned : cleaned.slice(cleaned.length - MAX_FILE_NAME_CHARS)
415
+ }
416
+
417
+ function tooLarge(kind: 'image' | 'file', bytes: number): Extract<SendFileValue, { ok: false }> {
418
+ const limit = kind === 'image' ? FEISHU_IMAGE_MAX_BYTES : FEISHU_FILE_MAX_BYTES
419
+ const hint = kind === 'image' ? ';可用 kind=file 以文件发送(上限 30 MB)' : ';请压缩或拆分后再发送'
420
+ return failure('FILE_TOO_LARGE', `${FAILURE_REASONS.FILE_TOO_LARGE}:${formatBytes(bytes)} > ${kind === 'image' ? '图片' : '文件'}上限 ${formatBytes(limit)}${hint}`)
421
+ }
422
+
423
+ function failure(code: SendFileFailureCode, reason?: string): Extract<SendFileValue, { ok: false }> {
424
+ return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
425
+ }
426
+
427
+ // ---- Errors -------------------------------------------------------------
428
+
429
+ /** Feishu codes for a recalled/deleted reply target (the SDK's `target_revoked`). */
430
+ const REPLY_TARGET_GONE_CODES: ReadonlySet<number> = new Set([230020, 230017])
431
+
432
+ /** Feishu codes for a missing app scope. */
433
+ const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679, 230027])
434
+
435
+ /** The thrown value followed by its `cause` chain (bounded). */
436
+ function errorChain(error: unknown): unknown[] {
437
+ const chain: unknown[] = []
438
+ let current = error
439
+ while (current !== undefined && current !== null && chain.length < 4) {
440
+ chain.push(current)
441
+ current = typeof current === 'object' ? (current as { cause?: unknown }).cause : undefined
442
+ }
443
+ return chain
444
+ }
445
+
446
+ /** Feishu `{ code, msg }` of an SDK/axios error, when present. */
447
+ function feishuBody(error: unknown): { code?: number; msg?: string } | undefined {
448
+ if (typeof error !== 'object' || error === null) return undefined
449
+ if (error instanceof FeishuApiError) return { ...(error.code === undefined ? {} : { code: error.code }), msg: error.detail }
450
+ const record = error as { code?: unknown; msg?: unknown; response?: { data?: unknown } }
451
+ const data = record.response?.data
452
+ const source = typeof data === 'object' && data !== null ? data as { code?: unknown; msg?: unknown } : record
453
+ const code = typeof source.code === 'number' ? source.code : undefined
454
+ const msg = typeof source.msg === 'string' ? source.msg : undefined
455
+ return code === undefined && msg === undefined ? undefined : {
456
+ ...(code === undefined ? {} : { code }),
457
+ ...(msg === undefined ? {} : { msg }),
458
+ }
459
+ }
460
+
461
+ function isPermissionError(error: unknown): boolean {
462
+ return errorChain(error).some((item) => {
463
+ const body = feishuBody(item)
464
+ if (body?.code !== undefined && PERMISSION_CODES.has(body.code)) return true
465
+ const text = `${body?.msg ?? ''} ${item instanceof Error ? item.message : ''}`
466
+ return /status code 403|access denied|scope|permission/iu.test(text)
467
+ })
468
+ }
469
+
470
+ /** Readable, bounded text for a thrown value and its Feishu cause. */
471
+ function describeError(error: unknown): string {
472
+ const parts: string[] = []
473
+ for (const item of errorChain(error)) {
474
+ const body = feishuBody(item)
475
+ if (body !== undefined) parts.push([body.code, body.msg].filter(part => part !== undefined).join(' '))
476
+ else if (item instanceof Error && item.message !== '') parts.push(item.message)
477
+ else if (typeof item === 'string' && item !== '') parts.push(item)
478
+ }
479
+ const text = parts.length === 0 ? '未知错误' : [...new Set(parts)].join(':')
480
+ return displayText(text)
481
+ }
482
+
483
+ function abortable<T>(task: Promise<T>, signal: AbortSignal): Promise<T> {
484
+ if (signal.aborted) return Promise.reject(new Error('aborted'))
485
+ return new Promise<T>((resolve, reject) => {
486
+ const onAbort = (): void => reject(new Error('aborted'))
487
+ signal.addEventListener('abort', onAbort, { once: true })
488
+ task.then(
489
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
490
+ (error: unknown) => { signal.removeEventListener('abort', onAbort); reject(error) },
491
+ )
492
+ })
493
+ }
494
+
495
+ // ---- Rendering / presentation -------------------------------------------
496
+
497
+ function displayText(value: string, max = 300): string {
498
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
499
+ return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max)}…`
500
+ }
501
+
502
+ function formatBytes(bytes: number): string {
503
+ if (bytes < 1024) return `${bytes} B`
504
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
505
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
506
+ }
507
+
508
+ function isSendFileValue(value: unknown): value is SendFileValue {
509
+ return typeof value === 'object' && value !== null && typeof (value as { ok?: unknown }).ok === 'boolean'
510
+ }
511
+
512
+ /**
513
+ * Model-visible content of one call.
514
+ *
515
+ * @param value The tool value.
516
+ * @returns One text block.
517
+ */
518
+ export function renderSendFileResult(value: unknown): readonly ToolContentBlock[] {
519
+ return [{ type: 'text', text: presentSendFileResult(value) }]
520
+ }
521
+
522
+ /**
523
+ * One-line call presentation.
524
+ *
525
+ * @param args Raw tool arguments.
526
+ * @returns Display text.
527
+ */
528
+ export function presentSendFileCall(args: Record<string, unknown>): string {
529
+ const target = typeof args.path === 'string' ? displayText(args.path, 120) : '?'
530
+ return `发送到飞书:${target}`
531
+ }
532
+
533
+ /**
534
+ * One-line result presentation.
535
+ *
536
+ * @param value The tool value.
537
+ * @returns Display text.
538
+ */
539
+ export function presentSendFileResult(value: unknown): string {
540
+ if (!isSendFileValue(value)) return '发送结果不可用'
541
+ if (value.ok) return value.summary
542
+ return `发送失败(${value.code}):${value.reason}`
543
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Native slash-command panel sync: keeps the commands Feishu lists when a
3
+ * user types `/` in a chat with the bot (`application/v7/app_slash_commands`)
4
+ * equal to the channel command registry.
5
+ *
6
+ * The plugin owns the bot's whole command vocabulary (one dsh instance per
7
+ * bot), so the sync is a full reconcile: missing commands are created,
8
+ * commands whose description changed are updated in place, and commands the
9
+ * registry no longer has (or duplicates) are deleted. A second run with an
10
+ * unchanged registry issues no writes. Feishu clients cache the panel for
11
+ * about five minutes after a write.
12
+ *
13
+ * @module dsh-channels/feishu/slash-commands
14
+ */
15
+
16
+ import { CHANNEL_COMMANDS } from '../commands/registry.ts'
17
+ import type { ChannelCommandSpec } from '../commands/registry.ts'
18
+
19
+ /** One command as the plugin wants Feishu to list it. */
20
+ export interface SlashCommandEntry {
21
+ /** Command name without the leading slash. */
22
+ readonly command: string
23
+ /** Panel description (zh_cn and default text). */
24
+ readonly description: string
25
+ }
26
+
27
+ /** One command as Feishu currently lists it for the app. */
28
+ export interface RemoteSlashCommand {
29
+ /** Feishu `command_id`, needed for deletion. */
30
+ readonly id: string
31
+ readonly command: string
32
+ /** `description.default_value`; empty when Feishu omitted it. */
33
+ readonly description: string
34
+ }
35
+
36
+ /**
37
+ * The app's slash-command endpoints. Every method rejects with a
38
+ * `FeishuApiError` when Feishu answers a non-zero code or the transport fails.
39
+ */
40
+ export interface SlashCommandApi {
41
+ /** Every command currently registered for the app. */
42
+ list(): Promise<readonly RemoteSlashCommand[]>
43
+ /** Register one command. */
44
+ create(entry: SlashCommandEntry): Promise<void>
45
+ /** Replace the description of the command with Feishu id `id`. */
46
+ update(id: string, entry: SlashCommandEntry): Promise<void>
47
+ /** Delete one command by its Feishu id. */
48
+ delete(id: string): Promise<void>
49
+ }
50
+
51
+ /** Writes a reconcile needs, in execution order (deletions first). */
52
+ export interface SlashCommandSyncPlan {
53
+ /** Remote commands to delete: stale or duplicated. */
54
+ readonly remove: readonly RemoteSlashCommand[]
55
+ /** Remote commands whose description changed, with their new entry. */
56
+ readonly update: ReadonlyArray<{ readonly id: string; readonly entry: SlashCommandEntry }>
57
+ /** Entries Feishu does not list yet. */
58
+ readonly create: readonly SlashCommandEntry[]
59
+ }
60
+
61
+ /** Outcome of one successful reconcile. */
62
+ export interface SlashCommandSyncResult {
63
+ readonly created: number
64
+ readonly updated: number
65
+ readonly removed: number
66
+ /** Commands the panel lists after the sync. */
67
+ readonly total: number
68
+ }
69
+
70
+ /**
71
+ * The panel entries derived from the command registry, in registry order.
72
+ * Aliases are left out: the panel lists each command once.
73
+ *
74
+ * @returns One entry per canonical channel command.
75
+ */
76
+ export function desiredSlashCommands(): readonly SlashCommandEntry[] {
77
+ const specs: readonly ChannelCommandSpec[] = CHANNEL_COMMANDS
78
+ return specs.map(spec => ({ command: spec.name, description: spec.description }))
79
+ }
80
+
81
+ /**
82
+ * Diff the remote commands against the desired entries.
83
+ *
84
+ * @param remote Commands Feishu lists for the app.
85
+ * @param desired Entries the panel should list.
86
+ * @returns Deletions and creations; both empty when the panel is current.
87
+ */
88
+ export function planSlashCommandSync(
89
+ remote: readonly RemoteSlashCommand[],
90
+ desired: readonly SlashCommandEntry[],
91
+ ): SlashCommandSyncPlan {
92
+ const wanted = new Map(desired.map(entry => [entry.command, entry]))
93
+ const matched = new Set<string>()
94
+ const remove: RemoteSlashCommand[] = []
95
+ const update: Array<{ id: string; entry: SlashCommandEntry }> = []
96
+ for (const item of remote) {
97
+ const entry = wanted.get(item.command)
98
+ if (entry === undefined || matched.has(item.command)) {
99
+ remove.push(item)
100
+ continue
101
+ }
102
+ matched.add(item.command)
103
+ if (entry.description !== item.description) update.push({ id: item.id, entry })
104
+ }
105
+ return { remove, update, create: desired.filter(entry => !matched.has(entry.command)) }
106
+ }
107
+
108
+ /**
109
+ * Reconcile the app's panel with `desired`. Stops at the first failed call
110
+ * and rethrows it; a later run resumes from whatever state Feishu kept. Once
111
+ * `signal` aborts, no further write starts (a write already sent completes).
112
+ *
113
+ * @param api The app's slash-command endpoints.
114
+ * @param desired Entries the panel should list.
115
+ * @param signal Aborted when the caller stops waiting (timeout, shutdown).
116
+ * @returns Write counts and the resulting panel size.
117
+ */
118
+ export async function syncSlashCommands(
119
+ api: SlashCommandApi,
120
+ desired: readonly SlashCommandEntry[],
121
+ signal?: AbortSignal,
122
+ ): Promise<SlashCommandSyncResult> {
123
+ const plan = planSlashCommandSync(await api.list(), desired)
124
+ const writes = [
125
+ ...plan.remove.map(item => () => api.delete(item.id)),
126
+ ...plan.update.map(item => () => api.update(item.id, item.entry)),
127
+ ...plan.create.map(entry => () => api.create(entry)),
128
+ ]
129
+ for (const write of writes) {
130
+ signal?.throwIfAborted()
131
+ await write()
132
+ }
133
+ return { created: plan.create.length, updated: plan.update.length, removed: plan.remove.length, total: desired.length }
134
+ }