lark-remote 0.4.0 → 0.4.2

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 (41) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/inbound-media.d.ts +13 -20
  5. package/dist/bridge/inbound-media.js +107 -93
  6. package/dist/bridge/index.d.ts +6 -6
  7. package/dist/bridge/index.js +6 -12
  8. package/dist/card/approval-render.js +9 -8
  9. package/dist/cli.js +5 -1
  10. package/dist/config/dir.d.ts +9 -0
  11. package/dist/config/dir.js +69 -0
  12. package/dist/config/index.d.ts +22 -3
  13. package/dist/config/index.js +25 -3
  14. package/dist/connector/index.d.ts +22 -1
  15. package/dist/connector/index.js +79 -14
  16. package/dist/inbound/event.d.ts +41 -0
  17. package/dist/inbound/event.js +11 -0
  18. package/dist/inbound/placeholder.d.ts +26 -0
  19. package/dist/inbound/placeholder.js +149 -0
  20. package/dist/inbound/prompt.d.ts +20 -0
  21. package/dist/inbound/prompt.js +47 -0
  22. package/dist/inbound/receipt.d.ts +28 -0
  23. package/dist/inbound/receipt.js +62 -0
  24. package/dist/inbound/turn-assembler.d.ts +63 -0
  25. package/dist/inbound/turn-assembler.js +233 -0
  26. package/dist/inbound/turn.d.ts +48 -0
  27. package/dist/inbound/turn.js +10 -0
  28. package/dist/index.js +96 -71
  29. package/dist/router/card-helpers.d.ts +38 -2
  30. package/dist/router/card-helpers.js +79 -2
  31. package/dist/router/config/opencode.d.ts +1 -1
  32. package/dist/router/config/opencode.js +11 -0
  33. package/dist/router/index.d.ts +56 -6
  34. package/dist/router/index.js +256 -78
  35. package/dist/runner/common/connection-based-runner.js +6 -2
  36. package/dist/runner/dsh/fake-dsh-server.d.ts +6 -0
  37. package/dist/runner/dsh/fake-dsh-server.js +6 -0
  38. package/dist/runner/kimi/acp/runner.d.ts +1 -1
  39. package/dist/runner/kimi/acp/runner.js +1 -1
  40. package/dist/runner/opencode/acp/runner.d.ts +1 -1
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.2] - 2026-09-19
9
+
10
+ ### 新增
11
+
12
+ - **`/ws` `/ls` 列表卡关键词搜索**:两张列表卡各多出一行搜索输入框,输入关键词后**按回车提交**即可筛选。`/ws` 匹配别名或完整路径,`/ls` 只匹配**当前层**条目名(不递归子目录),均大小写不敏感、子串包含;计数与页码一律按过滤后的结果计算。筛选词不落服务端(只随卡片回调 payload 传递,且仅非空时注入),服务端保持零状态,未筛选时的卡片形态与旧版完全一致。筛选态下翻页/刷新/排序/使用/删除会保留关键词,进入其他目录或切换工作目录则清除筛选(换目录后旧关键词没有意义)
13
+
14
+ ### 修复
15
+
16
+ - **卡片提交指引不再写「点 ✓」**:飞书移动端的 `input` 组件没有提交图标,只有键盘的回车/完成键(真机反馈)。所有用户可见文案统一为「按回车提交」,包括分页页码跳转、别名编辑、指令文本编辑与审批卡的修改意见
17
+ - **取消筛选改走显式按钮**:「清空输入再回车」在部分客户端不会触发回调(官方文档未承诺空串会发回调),实测清空后回车没有反应。现在筛选态下搜索行右侧会出现**「清除筛选」**按钮,走显式标记强制清除,不再依赖「读不到输入值」这一隐式条件
18
+ - **别名编辑卡不再把「留空提交」当删除入口**:同一处隐式条件问题,删除别名统一走列表卡别名旁的 ✕
19
+
20
+ ### 变更
21
+
22
+ - 卡片布局文档(中英)补齐 CardKit `input` 的三条约束:没有逐键回调所以「边输边过滤」做不到、文案不写「点 ✓」、取消语义要有显式控件;`design.md` 中「input 提交图标」的表述改为「input 提交回传」(图标只是桌面端的一种触发方式)
23
+ - 英文文档补齐 `/ws` `/ls` 关键词搜索:`README.en.md` 与 `docs/en/usage.md` 的命令表和功能段跟上中文口径
24
+
25
+ ## [0.4.1] - 2026-09-18
26
+
27
+ ### 新增
28
+
29
+ - **入站消息统一处理(图 + 文合并为一个回合)**:图片、文件、视频、语音、表情、富文本内嵌图一律自动保存到当前工作目录(`.lark-remote-temp/<时间目录>/`),附件绝对路径**自动写进 agent 的提示词**,不再需要你把"文件已保存"提示卡整段转述给 agent。同一静默期窗口(700 毫秒)内到达的文字与附件合并成一个回合:先发图再发文字、先发文字再发图,agent 拿到的内容完全相同
30
+ - **附件按种类命名**:视频/语音/表情保留原文件名;无原名时按 MIME 类型或文件头自动补扩展名(`.mp4` / `.opus` / `.gif` 等)
31
+ - **不支持消息类型明确回执**:卡片、位置、投票、日程、合并转发等拿不到内容的消息,回一条"暂不支持"提示,不再把占位符静默塞给 agent;纯附件无文字时只回执"已保存 N 个文件" + 引导语,不自动起回合(避免误耗 token)
32
+ - **`--advance-help` 高级配置参考**:命令行可输出高级配置项说明
33
+ - **OpenCode 配置卡支持回合空闲超时**:`turnIdleTimeoutMinutes`(默认 30 分钟,0 关闭),回合长时间无输出自动结束
34
+
35
+ ### 修复
36
+
37
+ - **P0:以图片占位符开头的消息被误当 shell 命令执行**:飞书会把"图片 + 文字"合成富文本,图片被渲染成 `![image](…)` 后首字符恰好是 `!`,命中 `!` 命令判定,整段用户消息被送进 shell 执行。现在命令识别有前置条件:仅当消息是纯文本、剥离结构占位符后仍以 `/` 或 `!` 开头时才按命令处理,其余一律按文本转发
38
+
39
+ ### 变更
40
+
41
+ - `inboundMedia.maxFileSizeMb` 默认值 50 → **100 MB**(更大的飞书文件需要分片下载,SDK 不支持,超限提示里已写明)
42
+
8
43
  ## [0.4.0] - 2026-09-15
9
44
 
10
45
  ### 新增
package/README.en.md CHANGED
@@ -108,9 +108,9 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
108
108
  |---------|-------|----------|
109
109
  | `/help` | `/h` | Command list |
110
110
  | `/cd <path>` | - | Switch agent working directory (`~`, absolute/relative; clears the session) |
111
- | `/ls [dir\|file]` | - | Directory/file card; click a directory to browse, click a file ≤30MB to send it to Feishu; paginates beyond 30 entries with a page-number jump input. Passing a file path lists that file itself |
111
+ | `/ls [dir\|file]` | - | Directory/file card; click a directory to browse, click a file ≤30MB to send it to Feishu; paginates beyond 30 entries with a page-number jump input, and current-level entries can be filtered by keyword. Passing a file path lists that file itself |
112
112
  | `/download <path>` | `/d` | Send a local file straight to the chat (`~`, absolute/relative paths, 30MB cap) |
113
- | `/ws save\|use\|remove` | - | Named directory aliases (`/ws` lists by default) |
113
+ | `/ws save\|use\|remove` | - | Named directory aliases (`/ws` lists by default, filterable by alias name or path keyword) |
114
114
  | `/resume [agent] [N\|id]` | `/r` | List/switch agent sessions for the current directory (card) |
115
115
  | `/active` | - | List all running sessions |
116
116
  | `/new` | - | Clear the current session (keeps cwd) |
package/README.md CHANGED
@@ -108,9 +108,9 @@ lark-remote 启动后不在终端输出,运行日志写入 `~/.lark-remote/log
108
108
  |------|------|------|
109
109
  | `/help` | `/h` | 命令列表 |
110
110
  | `/cd <path>` | - | 切换 agent 工作目录(支持 `~`、绝对/相对路径,清空会话) |
111
- | `/ls [dir\|file]` | - | 弹出目录/文件卡片;点击目录切换,点击 30MB 内文件发送到飞书;条目 >30 时支持翻页与直接输入页码跳转。传文件路径时直接列出该文件(可下载) |
111
+ | `/ls [dir\|file]` | - | 弹出目录/文件卡片;点击目录切换,点击 30MB 内文件发送到飞书;条目 >30 时支持翻页与直接输入页码跳转,卡片内可按关键词筛选本层条目。传文件路径时直接列出该文件(可下载) |
112
112
  | `/download <path>` | `/d` | 直接下载(发送)指定文件到飞书(支持 `~`、绝对/相对路径,上限 30MB) |
113
- | `/ws save\|use\|remove` | - | 命名目录别名管理(`/ws` 默认列出) |
113
+ | `/ws save\|use\|remove` | - | 命名目录别名管理(`/ws` 默认列出,可按名称/路径关键词筛选) |
114
114
  | `/resume [agent] [N\|id]` | `/r` | 列出/切换当前目录的 agent session(卡片) |
115
115
  | `/active` | - | 列出所有正在进行中的 session |
116
116
  | `/new` | - | 清空当前会话(保留工作目录) |
@@ -1,16 +1,11 @@
1
1
  import { type AppConfig } from '../config/index.js';
2
- import type { InboundMediaItem, InboundMediaPayload } from '../connector/index.js';
2
+ import type { MediaOutcome } from '../inbound/turn.js';
3
+ import type { InboundResourceKind, InboundMediaItem, InboundMediaPayload } from '../connector/index.js';
3
4
  export interface InboundMediaDeps {
4
5
  /** 解析用户 cwd(bridge.resolveCwd 注入,统一三份复制点)。 */
5
6
  resolveCwd: (userId: string) => string | undefined;
6
7
  /** 读取当前配置(活引用,随 /config 保存更新,避免启动快照过期)。 */
7
8
  getConfig: () => AppConfig;
8
- /** 发送提示消息(bridge.sendResult 包装)。 */
9
- send(ctx: {
10
- userId: string;
11
- chatId: string;
12
- messageId: string;
13
- }, text: string): Promise<boolean>;
14
9
  }
15
10
  /**
16
11
  * 文件名 sanitize:
@@ -19,10 +14,10 @@ export interface InboundMediaDeps {
19
14
  */
20
15
  export declare function sanitizeFileName(name: string): string;
21
16
  /**
22
- * image 消息扩展名:优先 MIME 映射,未知时按魔数兜底;
17
+ * 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
23
18
  * 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
24
19
  */
25
- export declare function imageExtension(mimeType: string | undefined, head: Buffer): string | undefined;
20
+ export declare function extensionFor(_kind: InboundResourceKind, mimeType: string | undefined, head: Buffer): string | undefined;
26
21
  /**
27
22
  * 文件名长度限制(字节级,保留扩展名),避免 ENAMETOOLONG 整文件失败。
28
23
  * 极罕见情况(扩展名本身超限)直接截断全名。
@@ -30,23 +25,22 @@ export declare function imageExtension(mimeType: string | undefined, head: Buffe
30
25
  export declare function limitFileNameLength(name: string, maxBytes?: number): string;
31
26
  /**
32
27
  * 生成落盘文件名:
33
- * - file 消息:保留原始文件名(已 sanitize);无文件名兜底 file_<HHmmss>_<n>;
34
- * - image 消息:image_<HHmmss>_<n>.<ext>,ext 按 MIME。
28
+ * - image:`image_<HHmmss>_<n>.<ext>`(ext 按 MIME/魔数);
29
+ * - 其它有原名的资源(file/video/audio/sticker):保留原名(已 sanitize);
30
+ * - 无原名的资源:`<kind>_<HHmmss>_<n>.<ext>`,ext 按 MIME/魔数
31
+ * (旧实现落成无扩展名的 `file_HHmmss_n`,mp4/opus/gif 都不可识别)。
35
32
  */
36
33
  export declare function buildFileName(item: InboundMediaItem, index: number, receivedAt: Date): string;
37
34
  /** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
38
35
  export declare function uniqueTargetPath(dir: string, fileName: string): string;
39
36
  export declare class InboundMediaHandler {
40
37
  private readonly deps;
41
- private readonly pending;
42
- private readonly batchWindowMs;
43
38
  constructor(deps: InboundMediaDeps);
44
- /** 落盘并安排/合并提示。 */
45
- handle(payload: InboundMediaPayload): Promise<void>;
46
- /** 立即冲刷某用户的全部待合批提示(文本到达时调用)。 */
47
- flushAll(userId: string, chatId: string): void;
48
- /** 冲刷全部待合批提示(/exit、/restart 干净退出前调用,避免丢最后一条提示)。 */
49
- flushAllPending(): Promise<void>;
39
+ /**
40
+ * 落盘并返回结果(不改时间语义、不发消息)。
41
+ * 失败/超限/无 cwd 一律进 `rejected`,由装配器决定回执文案。
42
+ */
43
+ save(payload: InboundMediaPayload): Promise<MediaOutcome>;
50
44
  /**
51
45
  * dirName 配置校验:只允许单层目录名(不允许 `/`、`\`、`..`、`.`、控制字符),
52
46
  * 非法值回退默认 `.lark-remote-temp`,避免配置意外把文件写到 cwd 之外。
@@ -55,5 +49,4 @@ export declare class InboundMediaHandler {
55
49
  /** 清理未被移动的临时文件(无 cwd / 目录创建失败等提前返回路径)。 */
56
50
  private cleanupTemps;
57
51
  private resolveCwd;
58
- private flush;
59
52
  }
@@ -8,26 +8,37 @@ import { silentlyUnlink } from '../common/fs.js';
8
8
  * 入站媒体落盘。
9
9
  *
10
10
  * 核心设计:
11
- * - 存储"到达即存":每个 media 立即原子写入
11
+ * - 存储「到达即存」:每个 media 立即原子写入
12
12
  * `<cwd>/.lark-remote-temp/<YYYYMMDDHHmm>/`,不等待合批窗口;
13
- * - 合批只影响提示:500ms 窗口内同一 (userId, chatId, replyTo) 的保存结果
14
- * 合并成一条"已保存 N 个文件"提示;
15
- * - 文本消息到达时由 Bridge.flushMediaNotifications 立即冲刷批次
16
- * ("先图后文字时文字到达先冲刷批次"),避免提示被后续消息淹没。
13
+ * - 只负责「落盘 + 报告结果」:把落盘路径与失败原因交回装配器
14
+ * (`InboundTurnAssembler`),由装配器决定注入 prompt 还是发回执。
15
+ * 时间语义统一由装配器的静默期窗口负责(2026-09-15 起取代旧的
16
+ * 500ms 合批提示窗口 ——「现有合批窗口由 700ms 装配窗口取代」)。
17
17
  */
18
- /** 合批窗口(毫秒)。同一窗口内到达的保存提示合并为一条。 */
19
- const DEFAULT_BATCH_WINDOW_MS = 500;
20
- /** 单条提示最多列出的文件数;超出折叠为 "… 等 N 个文件"。 */
21
- const MAX_PATHS_IN_NOTIFICATION = 10;
22
18
  /** 文件名最长字节数(macOS/APFS 单组件上限 255 字节,留余量用 240)。 */
23
19
  const MAX_FILE_NAME_BYTES = 240;
24
- /** 魔数检测读取的头部字节数(png/jpg/gif/webp 签名都在前 12 字节内)。 */
20
+ /** 魔数检测读取的头部字节数(png/jpg/gif/webp/mp4/ogg 签名都在前 12 字节内)。 */
25
21
  const MAGIC_HEAD_BYTES = 12;
26
22
  const MIME_TO_EXT = {
27
23
  'image/png': 'png',
28
24
  'image/jpeg': 'jpg',
25
+ 'image/jpg': 'jpg',
29
26
  'image/gif': 'gif',
30
27
  'image/webp': 'webp',
28
+ 'image/bmp': 'bmp',
29
+ 'video/mp4': 'mp4',
30
+ 'video/quicktime': 'mov',
31
+ 'video/webm': 'webm',
32
+ 'video/x-matroska': 'mkv',
33
+ 'audio/opus': 'opus',
34
+ 'audio/ogg': 'ogg',
35
+ 'audio/mpeg': 'mp3',
36
+ 'audio/mp3': 'mp3',
37
+ 'audio/wav': 'wav',
38
+ 'audio/x-wav': 'wav',
39
+ 'audio/aac': 'aac',
40
+ 'audio/amr': 'amr',
41
+ 'audio/mp4': 'm4a',
31
42
  };
32
43
  function pad2(n) {
33
44
  return String(n).padStart(2, '0');
@@ -74,17 +85,23 @@ export function sanitizeFileName(name) {
74
85
  return sanitized;
75
86
  }
76
87
  /**
77
- * image 消息扩展名:优先 MIME 映射,未知时按魔数兜底;
88
+ * 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
78
89
  * 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
79
90
  */
80
- export function imageExtension(mimeType, head) {
81
- if (mimeType && MIME_TO_EXT[mimeType])
82
- return MIME_TO_EXT[mimeType];
91
+ export function extensionFor(_kind, mimeType, head) {
92
+ const normalized = mimeType?.split(';')[0]?.trim().toLowerCase();
93
+ if (normalized && MIME_TO_EXT[normalized])
94
+ return MIME_TO_EXT[normalized];
83
95
  if (head.length >= 12 &&
84
96
  head.subarray(0, 4).toString('latin1') === 'RIFF' &&
85
97
  head.subarray(8, 12).toString('latin1') === 'WEBP') {
86
98
  return 'webp';
87
99
  }
100
+ if (head.length >= 12 &&
101
+ head.subarray(0, 4).toString('latin1') === 'RIFF' &&
102
+ head.subarray(8, 12).toString('latin1') === 'WAVE') {
103
+ return 'wav';
104
+ }
88
105
  if (head.length >= 8 &&
89
106
  head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
90
107
  return 'png';
@@ -95,6 +112,29 @@ export function imageExtension(mimeType, head) {
95
112
  if (head.length >= 4 && head.subarray(0, 4).toString('latin1') === 'GIF8') {
96
113
  return 'gif';
97
114
  }
115
+ if (head.length >= 4 && head.subarray(0, 4).toString('latin1') === 'OggS') {
116
+ return 'opus';
117
+ }
118
+ // MP4/MOV 家族:ISO BMFF 在偏移 4 处写 'ftyp'。
119
+ if (head.length >= 8 && head.subarray(4, 8).toString('latin1') === 'ftyp') {
120
+ return 'mp4';
121
+ }
122
+ if (head.length >= 2 && head[0] === 0x42 && head[1] === 0x4d) {
123
+ return 'bmp';
124
+ }
125
+ if (head.length >= 3 && head.subarray(0, 3).toString('latin1') === 'ID3') {
126
+ return 'mp3';
127
+ }
128
+ // MPEG audio frame sync(0xFFE0 掩码)+ 常见 layer/bitrate 组合。
129
+ if (head.length >= 2 && head[0] === 0xff && (head[1] & 0xfe) === 0xfa) {
130
+ return 'mp3';
131
+ }
132
+ if (head.length >= 5 && head.subarray(0, 5).toString('latin1') === '#!AMR') {
133
+ return 'amr';
134
+ }
135
+ if (head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3) {
136
+ return 'mkv';
137
+ }
98
138
  return undefined;
99
139
  }
100
140
  /** 按 UTF-8 字节数截断字符串(避免截在多字节字符中间)。 */
@@ -127,17 +167,17 @@ function readFileHead(filePath, maxBytes = MAGIC_HEAD_BYTES) {
127
167
  }
128
168
  /**
129
169
  * 生成落盘文件名:
130
- * - file 消息:保留原始文件名(已 sanitize);无文件名兜底 file_<HHmmss>_<n>;
131
- * - image 消息:image_<HHmmss>_<n>.<ext>,ext 按 MIME。
170
+ * - image:`image_<HHmmss>_<n>.<ext>`(ext 按 MIME/魔数);
171
+ * - 其它有原名的资源(file/video/audio/sticker):保留原名(已 sanitize);
172
+ * - 无原名的资源:`<kind>_<HHmmss>_<n>.<ext>`,ext 按 MIME/魔数
173
+ * (旧实现落成无扩展名的 `file_HHmmss_n`,mp4/opus/gif 都不可识别)。
132
174
  */
133
175
  export function buildFileName(item, index, receivedAt) {
134
- if (item.type === 'file') {
135
- const sanitized = item.fileName ? sanitizeFileName(item.fileName) : '';
136
- return ((sanitized ? limitFileNameLength(sanitized) : '') ||
137
- `file_${timeStampHms(receivedAt)}_${index}`);
138
- }
139
- const ext = imageExtension(item.mimeType, readFileHead(item.tempPath));
140
- return `image_${timeStampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
176
+ const original = item.fileName ? limitFileNameLength(sanitizeFileName(item.fileName)) : '';
177
+ if (item.kind !== 'image' && original)
178
+ return original;
179
+ const ext = extensionFor(item.kind, item.mimeType, readFileHead(item.tempPath));
180
+ return `${item.kind}_${timeStampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
141
181
  }
142
182
  /** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
143
183
  export function uniqueTargetPath(dir, fileName) {
@@ -151,27 +191,29 @@ export function uniqueTargetPath(dir, fileName) {
151
191
  }
152
192
  return candidate;
153
193
  }
154
- function formatFailure(f) {
155
- return f.fileName ? `${f.fileName}: ${f.reason}` : f.reason;
156
- }
157
- function batchKey(payload) {
158
- return `${payload.userId}:${payload.chatId}:${payload.replyToMessageId ?? ''}`;
159
- }
160
194
  export class InboundMediaHandler {
161
195
  deps;
162
- pending = new Map();
163
- batchWindowMs;
164
196
  constructor(deps) {
165
197
  this.deps = deps;
166
- this.batchWindowMs = DEFAULT_BATCH_WINDOW_MS;
167
198
  }
168
- /** 落盘并安排/合并提示。 */
169
- async handle(payload) {
199
+ /**
200
+ * 落盘并返回结果(不改时间语义、不发消息)。
201
+ * 失败/超限/无 cwd 一律进 `rejected`,由装配器决定回执文案。
202
+ */
203
+ async save(payload) {
170
204
  const cwd = this.resolveCwd(payload.userId);
171
205
  if (!cwd) {
172
206
  this.cleanupTemps(payload);
173
- await this.deps.send({ userId: payload.userId, chatId: payload.chatId, messageId: payload.messageId }, '⚠️ 未设置工作目录,无法保存文件。请先使用 /cd <path> 或 /ws use 设置');
174
- return;
207
+ return {
208
+ attachments: [],
209
+ rejected: [
210
+ {
211
+ kind: 'file',
212
+ reason: '未设置工作目录,无法保存文件。请先使用 /cd <path> 或 /ws use 设置',
213
+ sourceMsgId: payload.messageId,
214
+ },
215
+ ],
216
+ };
175
217
  }
176
218
  const receivedAt = new Date();
177
219
  const dir = path.join(cwd, this.safeDirName(), timeStampDir(receivedAt));
@@ -181,55 +223,49 @@ export class InboundMediaHandler {
181
223
  catch (err) {
182
224
  // 写盘失败必须明确提示;临时文件一并清理避免泄漏。
183
225
  this.cleanupTemps(payload);
184
- await this.deps.send({ userId: payload.userId, chatId: payload.chatId, messageId: payload.messageId }, `⚠️ 保存失败:无法创建目录 ${dir}:${err.message}`);
185
- return;
226
+ return {
227
+ attachments: [],
228
+ rejected: [
229
+ {
230
+ kind: 'file',
231
+ reason: `保存失败:无法创建目录 ${dir}:${err.message}`,
232
+ sourceMsgId: payload.messageId,
233
+ },
234
+ ],
235
+ };
186
236
  }
187
- const errors = payload.failures.map(formatFailure);
188
- const saved = [];
237
+ const rejected = payload.failures.map((f) => ({
238
+ kind: f.kind ?? 'file',
239
+ reason: f.fileName ? `${f.fileName}: ${f.reason}` : f.reason,
240
+ sourceMsgId: payload.messageId,
241
+ }));
242
+ const attachments = [];
189
243
  for (let i = 0; i < payload.media.length; i += 1) {
190
244
  const item = payload.media[i];
191
245
  try {
192
246
  const target = uniqueTargetPath(dir, buildFileName(item, i + 1, receivedAt));
193
247
  atomicMoveFile(item.tempPath, target);
194
- saved.push(target);
248
+ attachments.push({
249
+ path: target,
250
+ kind: item.kind,
251
+ sourceMsgId: payload.messageId,
252
+ originalName: item.fileName,
253
+ durationMs: item.durationMs,
254
+ });
195
255
  }
196
256
  catch (err) {
197
257
  silentlyUnlink(item.tempPath);
198
258
  const label = item.fileName
199
259
  ? sanitizeFileName(item.fileName) || item.fileName
200
260
  : `第 ${i + 1} 个`;
201
- errors.push(`${label}: ${err.message}`);
261
+ rejected.push({
262
+ kind: item.kind,
263
+ reason: `${label}: ${err.message}`,
264
+ sourceMsgId: payload.messageId,
265
+ });
202
266
  }
203
267
  }
204
- if (saved.length === 0) {
205
- await this.deps.send({ userId: payload.userId, chatId: payload.chatId, messageId: payload.messageId }, `⚠️ 保存失败:${errors.join(';') || '未知错误'}`);
206
- return;
207
- }
208
- const key = batchKey(payload);
209
- const existing = this.pending.get(key);
210
- if (existing) {
211
- existing.saved.push(...saved);
212
- existing.errors.push(...errors);
213
- clearTimeout(existing.timer);
214
- existing.timer = setTimeout(() => void this.flush(key), this.batchWindowMs);
215
- return;
216
- }
217
- const timer = setTimeout(() => void this.flush(key), this.batchWindowMs);
218
- this.pending.set(key, { payload, saved, errors, timer });
219
- }
220
- /** 立即冲刷某用户的全部待合批提示(文本到达时调用)。 */
221
- flushAll(userId, chatId) {
222
- for (const key of [...this.pending.keys()]) {
223
- const batch = this.pending.get(key);
224
- if (batch && batch.payload.userId === userId && batch.payload.chatId === chatId) {
225
- void this.flush(key);
226
- }
227
- }
228
- }
229
- /** 冲刷全部待合批提示(/exit、/restart 干净退出前调用,避免丢最后一条提示)。 */
230
- async flushAllPending() {
231
- const keys = [...this.pending.keys()];
232
- await Promise.all(keys.map((key) => this.flush(key)));
268
+ return { attachments, rejected };
233
269
  }
234
270
  /**
235
271
  * dirName 配置校验:只允许单层目录名(不允许 `/`、`\`、`..`、`.`、控制字符),
@@ -257,27 +293,5 @@ export class InboundMediaHandler {
257
293
  resolveCwd(userId) {
258
294
  return this.deps.resolveCwd(userId);
259
295
  }
260
- async flush(key) {
261
- const batch = this.pending.get(key);
262
- if (!batch)
263
- return;
264
- this.pending.delete(key);
265
- clearTimeout(batch.timer);
266
- const lines = batch.saved.slice(0, MAX_PATHS_IN_NOTIFICATION).map((p) => `- ${p}`);
267
- const overflow = batch.saved.length > MAX_PATHS_IN_NOTIFICATION ? `\n… 等 ${batch.saved.length} 个文件` : '';
268
- const failures = batch.errors.length > 0 ? `\n⚠️ ${batch.errors.join(';')}` : '';
269
- // 提示带上保存目录:用户把整句转给 agent 即可定位,无需靠目录约定反查。
270
- // 合批窗口跨分钟时可能落在两个目录,此时全部列出。
271
- const dirs = [...new Set(batch.saved.map((p) => path.dirname(p)))];
272
- const hint = dirs.length === 1
273
- ? `\n💡 你可以直接说:请处理 ${dirs[0]} 下的文件`
274
- : `\n💡 你可以直接说:请处理以下目录下的文件:${dirs.join('、')}`;
275
- const text = `📎 已保存 ${batch.saved.length} 个文件:\n` + lines.join('\n') + overflow + failures + hint;
276
- await this.deps.send({
277
- userId: batch.payload.userId,
278
- chatId: batch.payload.chatId,
279
- messageId: batch.payload.messageId,
280
- }, text);
281
- }
282
296
  }
283
297
  //# sourceMappingURL=inbound-media.js.map
@@ -7,6 +7,7 @@ import type { RunTerminal } from '../card/run-state.js';
7
7
  import type { AgentKind } from '../runner/types.js';
8
8
  import { QueueManager, type QueuedTask, type EnqueueOptions, type AgentBinding } from './queue-manager.js';
9
9
  import type { InboundMediaPayload } from '../connector/index.js';
10
+ import type { MediaOutcome } from '../inbound/turn.js';
10
11
  /**
11
12
  * Seam (§7): the channel capabilities the bridge needs. `sendWithRetry` is
12
13
  * the minimum a Feishu channel must offer for the bridge to push messages
@@ -318,12 +319,11 @@ export declare class Bridge {
318
319
  updateCardInPlace(card: object, ctx: BridgeContext): Promise<boolean>;
319
320
  /** Send a file to the user via Feishu. */
320
321
  sendFile(filePath: string, ctx: BridgeContext): Promise<void>;
321
- /** 入站媒体落盘入口(connector 下载完成后调用)。 */
322
- onInboundMedia(payload: InboundMediaPayload): Promise<void>;
323
- /** 文本消息到达时立即冲刷待合批的媒体保存提示。 */
324
- flushMediaNotifications(userId: string, chatId: string): void;
325
- /** 冲刷全部待合批提示(/exit、/restart 干净退出前调用)。 */
326
- flushAllMediaNotifications(): Promise<void>;
322
+ /**
323
+ * 入站媒体落盘(connector 下载完成后调用):只落盘并回报结果,
324
+ * 时间语义与回执由 `InboundTurnAssembler` 统一负责。
325
+ */
326
+ saveInboundMedia(payload: InboundMediaPayload): Promise<MediaOutcome>;
327
327
  /**
328
328
  * Resolve the working directory for a user.
329
329
  * First checks sessionStore (set by /cd), then falls back to first workspace.
@@ -135,7 +135,6 @@ export class Bridge {
135
135
  this.mediaHandler = new InboundMediaHandler({
136
136
  resolveCwd: (userId) => this.resolveCwd(userId),
137
137
  getConfig: () => this.config,
138
- send: async (ctx, text) => this.sendResult({ text }, ctx),
139
138
  });
140
139
  }
141
140
  /**
@@ -671,17 +670,12 @@ export class Bridge {
671
670
  await this.sendResult({ text: `发送文件失败: ${err.message}` }, ctx);
672
671
  }
673
672
  }
674
- /** 入站媒体落盘入口(connector 下载完成后调用)。 */
675
- async onInboundMedia(payload) {
676
- await this.mediaHandler.handle(payload);
677
- }
678
- /** 文本消息到达时立即冲刷待合批的媒体保存提示。 */
679
- flushMediaNotifications(userId, chatId) {
680
- this.mediaHandler.flushAll(userId, chatId);
681
- }
682
- /** 冲刷全部待合批提示(/exit、/restart 干净退出前调用)。 */
683
- async flushAllMediaNotifications() {
684
- await this.mediaHandler.flushAllPending();
673
+ /**
674
+ * 入站媒体落盘(connector 下载完成后调用):只落盘并回报结果,
675
+ * 时间语义与回执由 `InboundTurnAssembler` 统一负责。
676
+ */
677
+ async saveInboundMedia(payload) {
678
+ return this.mediaHandler.save(payload);
685
679
  }
686
680
  /**
687
681
  * Resolve the working directory for a user.
@@ -207,9 +207,9 @@ function renderPlanApproval(approval, expired, runId) {
207
207
  return elements;
208
208
  }
209
209
  /**
210
- * 计划修改意见输入行:输入后点击右侧 ✓ 提交图标,input_value 经 connector
211
- * raw 事件回传(CardKit input 的 input_value 会被 SDK normalizer 丢弃,
212
- * 必须走 includeRawEvent 路径,同 answerNote)。
210
+ * 计划修改意见输入行:输入后提交(移动端键盘回车/完成键,桌面端右侧提交图标),
211
+ * input_value 经 connector raw 事件回传(CardKit input 的 input_value 会被
212
+ * SDK normalizer 丢弃,必须走 includeRawEvent 路径,同 answerNote)。
213
213
  */
214
214
  function renderPlanFeedbackInput(requestId, runId) {
215
215
  return {
@@ -430,8 +430,9 @@ function renderQuestionApproval(approval, expired, runId) {
430
430
  text: { tag: 'lark_md', content: `✍️ 自定义答案:${customSelected}` },
431
431
  });
432
432
  }
433
- // review P3-4:单选问题提供自定义答案(Other)输入——输入文本后点击
434
- // 输入框右侧 ✓ 提交图标,input_value 经 connector raw 事件回传。
433
+ // review P3-4:单选问题提供自定义答案(Other)输入——输入文本后提交
434
+ // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
435
+ // connector raw 事件回传。
435
436
  // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
436
437
  // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
437
438
  if (!q.multiSelect && q.isOther !== false) {
@@ -517,9 +518,9 @@ function renderOptionRow(requestId, questionIndex, option, isSelected, icon, run
517
518
  };
518
519
  }
519
520
  /**
520
- * 文本输入行(Other 输入与自由文本题共用):输入后点击右侧 ✓ 提交图标,
521
- * input_value 经 connector raw 事件回传(CardKit input 的 input_value 会被
522
- * SDK normalizer 丢弃,必须走 includeRawEvent 路径)。
521
+ * 文本输入行(Other 输入与自由文本题共用):输入后提交(移动端键盘回车/完成键,
522
+ * 桌面端右侧提交图标),input_value 经 connector raw 事件回传(CardKit input 的
523
+ * input_value 会被 SDK normalizer 丢弃,必须走 includeRawEvent 路径)。
523
524
  */
524
525
  function renderAnswerInput(requestId, questionIndex, placeholder, runId, cmd = 'approval.answerCustom') {
525
526
  // name 必须含 cmd,保证同一题同时渲染多个输入框(如单选选项题的
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { printHelp, printVersion } from './config/dir.js';
6
+ import { printAdvanceHelp, printHelp, printVersion } from './config/dir.js';
7
7
  const entryUrl = new URL('./index.js', import.meta.url);
8
8
  const entry = fileURLToPath(entryUrl);
9
9
  /**
@@ -32,6 +32,10 @@ export function handlePreflight(args, nodeVersion = process.versions.node) {
32
32
  printHelp();
33
33
  return 0;
34
34
  }
35
+ if (arg === '--advance-help') {
36
+ printAdvanceHelp();
37
+ return 0;
38
+ }
35
39
  }
36
40
  const major = parseInt(String(nodeVersion).split('.')[0], 10);
37
41
  if (!Number.isNaN(major) && major > 0 && major < MIN_NODE_MAJOR) {
@@ -5,6 +5,7 @@ interface CliArgs {
5
5
  help?: boolean;
6
6
  version?: boolean;
7
7
  update?: boolean;
8
+ advanceHelp?: boolean;
8
9
  }
9
10
  export declare function resolveConfigDir(configDirArg: string | undefined): string;
10
11
  export declare function getConfigDir(): string;
@@ -16,4 +17,12 @@ export declare function getVersion(): string;
16
17
  export declare function printVersion(): void;
17
18
  /** Print CLI help to stdout. */
18
19
  export declare function printHelp(): void;
20
+ /**
21
+ * Print advanced-config reference to stdout (CLI `--advance-help`).
22
+ *
23
+ * 目标读者是 coding agent(用户让 agent 代改 config.yaml):披露所有
24
+ * /config 卡片未暴露的 YAML 字段——名称、默认值、语义、改法。飞书侧
25
+ * 等价命令为 /advancehelp(内容同源,卡片渲染)。
26
+ */
27
+ export declare function printAdvanceHelp(): void;
19
28
  export {};