@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.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- 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 +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- 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
|
+
}
|