lark-remote 0.3.0 → 0.4.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 (60) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.en.md +6 -6
  3. package/README.md +8 -7
  4. package/dist/binder.d.ts +1 -1
  5. package/dist/binder.js +1 -1
  6. package/dist/bridge/final-usage.js +2 -2
  7. package/dist/bridge/inbound-media.d.ts +13 -20
  8. package/dist/bridge/inbound-media.js +107 -91
  9. package/dist/bridge/index.d.ts +6 -6
  10. package/dist/bridge/index.js +81 -87
  11. package/dist/cli.js +5 -1
  12. package/dist/clone.d.ts +1 -1
  13. package/dist/common/fs.js +1 -1
  14. package/dist/config/dir.d.ts +9 -0
  15. package/dist/config/dir.js +69 -0
  16. package/dist/config/index.d.ts +22 -3
  17. package/dist/config/index.js +26 -4
  18. package/dist/connector/index.d.ts +28 -1
  19. package/dist/connector/index.js +155 -31
  20. package/dist/error-classification.d.ts +7 -0
  21. package/dist/error-classification.js +37 -1
  22. package/dist/inbound/event.d.ts +41 -0
  23. package/dist/inbound/event.js +11 -0
  24. package/dist/inbound/placeholder.d.ts +26 -0
  25. package/dist/inbound/placeholder.js +149 -0
  26. package/dist/inbound/prompt.d.ts +20 -0
  27. package/dist/inbound/prompt.js +47 -0
  28. package/dist/inbound/receipt.d.ts +28 -0
  29. package/dist/inbound/receipt.js +62 -0
  30. package/dist/inbound/turn-assembler.d.ts +63 -0
  31. package/dist/inbound/turn-assembler.js +233 -0
  32. package/dist/inbound/turn.d.ts +48 -0
  33. package/dist/inbound/turn.js +10 -0
  34. package/dist/index.js +108 -73
  35. package/dist/platform/command.js +1 -1
  36. package/dist/platform/sleep-blocker.d.ts +17 -0
  37. package/dist/platform/sleep-blocker.js +73 -0
  38. package/dist/platform/terminator-win32.js +1 -1
  39. package/dist/restart.d.ts +4 -4
  40. package/dist/restart.js +5 -5
  41. package/dist/router/config/kimi.js +4 -3
  42. package/dist/router/config/opencode.d.ts +1 -1
  43. package/dist/router/config/opencode.js +11 -0
  44. package/dist/router/index.d.ts +17 -0
  45. package/dist/router/index.js +53 -15
  46. package/dist/runner/common/acp/base-acp-runner.js +7 -2
  47. package/dist/runner/common/acp/base-translator.d.ts +19 -0
  48. package/dist/runner/common/acp/base-translator.js +82 -0
  49. package/dist/runner/common/acp/protocol-types.d.ts +16 -2
  50. package/dist/runner/common/acp/protocol-types.js +3 -0
  51. package/dist/runner/common/connection-based-runner.js +6 -2
  52. package/dist/runner/kimi/acp/runner.d.ts +1 -1
  53. package/dist/runner/kimi/acp/runner.js +1 -1
  54. package/dist/runner/kimi/acp/translator.js +60 -4
  55. package/dist/runner/opencode/acp/runner.d.ts +1 -1
  56. package/dist/session/session-store.d.ts +1 -1
  57. package/dist/session/session-store.js +1 -1
  58. package/dist/workspace/index.d.ts +1 -1
  59. package/dist/workspace/index.js +1 -1
  60. 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.1] - 2026-09-18
9
+
10
+ ### 新增
11
+
12
+ - **入站消息统一处理(图 + 文合并为一个回合)**:图片、文件、视频、语音、表情、富文本内嵌图一律自动保存到当前工作目录(`.lark-remote-temp/<时间目录>/`),附件绝对路径**自动写进 agent 的提示词**,不再需要你把"文件已保存"提示卡整段转述给 agent。同一静默期窗口(700 毫秒)内到达的文字与附件合并成一个回合:先发图再发文字、先发文字再发图,agent 拿到的内容完全相同
13
+ - **附件按种类命名**:视频/语音/表情保留原文件名;无原名时按 MIME 类型或文件头自动补扩展名(`.mp4` / `.opus` / `.gif` 等)
14
+ - **不支持消息类型明确回执**:卡片、位置、投票、日程、合并转发等拿不到内容的消息,回一条"暂不支持"提示,不再把占位符静默塞给 agent;纯附件无文字时只回执"已保存 N 个文件" + 引导语,不自动起回合(避免误耗 token)
15
+ - **`--advance-help` 高级配置参考**:命令行可输出高级配置项说明
16
+ - **OpenCode 配置卡支持回合空闲超时**:`turnIdleTimeoutMinutes`(默认 30 分钟,0 关闭),回合长时间无输出自动结束
17
+
18
+ ### 修复
19
+
20
+ - **P0:以图片占位符开头的消息被误当 shell 命令执行**:飞书会把"图片 + 文字"合成富文本,图片被渲染成 `![image](…)` 后首字符恰好是 `!`,命中 `!` 命令判定,整段用户消息被送进 shell 执行。现在命令识别有前置条件:仅当消息是纯文本、剥离结构占位符后仍以 `/` 或 `!` 开头时才按命令处理,其余一律按文本转发
21
+
22
+ ### 变更
23
+
24
+ - `inboundMedia.maxFileSizeMb` 默认值 50 → **100 MB**(更大的飞书文件需要分片下载,SDK 不支持,超限提示里已写明)
25
+
26
+ ## [0.4.0] - 2026-09-15
27
+
28
+ ### 新增
29
+
30
+ - **运行期间阻止系统休眠**:运行中自动阻止系统休眠(macOS 用 `caffeinate`、Windows 用 `SetThreadExecutionState`)——典型用法是人不在电脑前远程使用,系统一旦休眠连接即断。只阻止系统休眠,不影响显示器睡眠;退出(含异常退出)后自动解除,helper 意外退出也会留痕兜底。注意:MacBook 合盖睡眠无法阻止(除非接电源并外接显示器)
31
+ - **入站媒体保存提示带完整路径**:收到的图片/文件落盘后,提示带目录完整路径,方便直接定位
32
+
33
+ ### 修复
34
+
35
+ - **卡片更新瞬态失败自动重试**:飞书卡片更新遇到连接瞬时中断等瞬态传输错误时就地重试(最多 3 次),不再把失败直接落到未处理 rejection 上导致进程退出
36
+ - **Kimi 审批卡恢复完整 Bash 命令**:审批卡片按 `toolCallId` 关联流式参数,恢复展示完整命令而非截断片段
37
+ - **Kimi 权限模式文案对齐 kimi 0.40+ 语义**:`yolo` 现指「必要时询问」而非完全放行,完全自动请选 `auto`;README 与配置文档同步更新
38
+
39
+ ### 变更
40
+
41
+ - **文档自称统一**:README、使用文档与架构文档中的自称统一为 lark-remote
42
+
8
43
  ## [0.3.0] - 2026-09-13
9
44
 
10
45
  ### 新增
package/README.en.md CHANGED
@@ -16,7 +16,7 @@ Designed for a single-user, peer-to-peer private chat. The agent's system prompt
16
16
 
17
17
  ## ⚠️ Security Warning (read first)
18
18
 
19
- This tool turns Feishu messages directly into local agent executions. **Out of the box, agents run without approval prompts**: Claude defaults to `bypassPermissions` (no approval card), but that permission mode is **configurable** — set it to anything other than `bypassPermissions` (`default` / `acceptEdits` / `auto` / `dontAsk` / `plan`) to enable interactive approval, where high-risk actions wait for your confirmation on the Feishu card. Codex uses the **app-server approval mode** (default `approvalPolicy=on-request` — command execution requires your confirmation on the Feishu card, sandbox defaults to `workspace-write`); Kimi defaults to `manual` (per-action approval); other agents run with their CLI defaults. **In any mode**, agents can read and write the local directories you specify. You MUST:
19
+ This tool turns Feishu messages directly into local agent executions. **Out of the box, agents run without approval prompts**: Claude defaults to `bypassPermissions` (no approval card), but that permission mode is **configurable** — set it to anything other than `bypassPermissions` (`default` / `acceptEdits` / `auto` / `dontAsk` / `plan`) to enable interactive approval, where high-risk actions wait for your confirmation on the Feishu card. Codex uses the **app-server approval mode** (default `approvalPolicy=on-request` — command execution requires your confirmation on the Feishu card, sandbox defaults to `workspace-write`); Kimi defaults to `manual` (per-action approval; note that since kimi 0.40, `yolo` means Ask-When-Needed rather than allow-everything — use `auto` for fully unattended, no-approval execution); other agents run with their CLI defaults. **In any mode**, agents can read and write the local directories you specify. You MUST:
20
20
 
21
21
  - **Use it only in your own private (p2p) chat.** Never add the bot to any group chat; never let anyone else talk to it.
22
22
  - Restrict the app's visibility to **yourself only** in the Feishu Open Platform.
@@ -52,7 +52,7 @@ git clone https://github.com/bungabungawoda/lark-remote.git
52
52
  cd lark-remote
53
53
  bun install
54
54
  bun run build
55
- bun install -g "$(pwd)" # install globally as the lark-remote command (bun parses `.` as an empty package name; use an absolute path)
55
+ bun install -g "$(pwd)" # install globally as lark-remote command (bun parses `.` as an empty package name; use an absolute path)
56
56
  ```
57
57
 
58
58
  ## Configuration
@@ -94,7 +94,7 @@ lark-remote --config-dir ~/.lark-remote-test # custom config dir (multiple in
94
94
  lark-remote --settings ~/.claude/settings.json # specify Claude settings file
95
95
  ```
96
96
 
97
- The bridge prints nothing to the terminal after startup; logs go to `~/.lark-remote/logs/YYYY-MM-DD/lark-remote-<pid>.log` (rotated daily, one subdirectory per day). Only one `lark-remote` instance is allowed per `configDir` — a duplicate start fails with the existing pid. After connecting to Feishu, a startup notification (with start time and pid) is sent to the most recent private chat.
97
+ lark-remote prints nothing to the terminal after startup; logs go to `~/.lark-remote/logs/YYYY-MM-DD/lark-remote-<pid>.log` (rotated daily, one subdirectory per day). Only one `lark-remote` instance is allowed per `configDir` — a duplicate start fails with the existing pid. After connecting to Feishu, a startup notification (with start time and pid) is sent to the most recent private chat.
98
98
 
99
99
  Each agent run creates one CardKit 2.0 card that updates in place with thinking, body text, and tool summaries in real time.
100
100
  Timestamps are read from the JSONL and shown in local time as `YYYY-MM-DD HH:mm` for thinking, body, tool call/result, and session history events.
@@ -117,12 +117,12 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
117
117
  | `/status` | `/s` | Current directory, session, model, process status |
118
118
  | `/stop` | `/t` | Kill the current agent process (SIGKILL) |
119
119
  | `/ps` | - | Whether a process is running |
120
- | `/restart` | - | In-place bridge self-restart (new process takes over with the same config) |
121
- | `/clone [name]` | - | Clone the bridge: copies the current config into a new directory and walks you through creating a brand-new Feishu app by QR scan; on success it binds, syncs state, and launches the new instance |
120
+ | `/restart` | - | In-place lark-remote self-restart (new process takes over with the same config) |
121
+ | `/clone [name]` | - | Clone lark-remote: copies the current config into a new directory and walks you through creating a brand-new Feishu app by QR scan; on success it binds, syncs state, and launches the new instance |
122
122
  | `/config get\|set` | `/c` | View/change runtime config (agent-aware card) |
123
123
  | `/order save\|list\|edit` | `/o` | Save, list, or edit frequently used prompts |
124
124
  | `!<cmd>` | - | Run a bash command with streaming card output (bypasses the serial queue) |
125
- | `/exit` | `/e` | Exit the bridge |
125
+ | `/exit` | `/e` | Exit lark-remote |
126
126
 
127
127
  Any message not starting with `/` is forwarded to the current default agent.
128
128
 
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  ## ⚠️ 安全警告(必读)
18
18
 
19
- 本工具会把飞书消息直接转为本地 agent 执行。**开箱默认是不弹审批的**:Claude 默认以 `bypassPermissions` 运行(无审批卡),但该权限模式**可配置**——把它改成 `bypassPermissions` 以外的值(`default` / `acceptEdits` / `auto` / `dontAsk` / `plan`)即启用交互式审批,高风险操作会在飞书卡片上等你确认;Codex 采用 **app-server 审批模式**(默认 `approvalPolicy=on-request`,执行命令需在飞书卡片上确认,沙箱默认 `workspace-write`);Kimi 默认 `manual`(逐条审批);其他 agent 以各自 CLI 的默认权限运行。**无论哪种模式**,agent 都能读写你指定的本地目录,请务必:
19
+ 本工具会把飞书消息直接转为本地 agent 执行。**开箱默认是不弹审批的**:Claude 默认以 `bypassPermissions` 运行(无审批卡),但该权限模式**可配置**——把它改成 `bypassPermissions` 以外的值(`default` / `acceptEdits` / `auto` / `dontAsk` / `plan`)即启用交互式审批,高风险操作会在飞书卡片上等你确认;Codex 采用 **app-server 审批模式**(默认 `approvalPolicy=on-request`,执行命令需在飞书卡片上确认,沙箱默认 `workspace-write`);Kimi 默认 `manual`(逐条审批;注意 kimi 0.40+ 起 `yolo` 已改为「必要时询问」而非全部放行,要完全不弹审批需选 `auto`);其他 agent 以各自 CLI 的默认权限运行。**无论哪种模式**,agent 都能读写你指定的本地目录,请务必:
20
20
 
21
21
  - **仅限你自己的私聊(p2p)使用**。不要把机器人拉进任何群聊,不要让任何其他人能与它对话。
22
22
  - 在飞书开放平台把应用可见范围限制到**只有你自己**。
@@ -94,7 +94,7 @@ lark-remote --config-dir ~/.lark-remote-test # 自定义配置目录(同机
94
94
  lark-remote --settings ~/.claude/settings.json # 指定 Claude 配置文件
95
95
  ```
96
96
 
97
- bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYYY-MM-DD/lark-remote-<pid>.log`(按日期轮转,每天一个子目录)。同一 `configDir` 只允许一个 `lark-remote` 实例,重复启动会直接报错并提示已有 pid。连接飞书成功后会向最近私聊用户发送启动通知,包含启动时间和进程号。
97
+ lark-remote 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYYY-MM-DD/lark-remote-<pid>.log`(按日期轮转,每天一个子目录)。同一 `configDir` 只允许一个 `lark-remote` 实例,重复启动会直接报错并提示已有 pid。连接飞书成功后会向最近私聊用户发送启动通知,包含启动时间和进程号。
98
98
 
99
99
  每次 agent 运行会创建一张 CardKit 2.0 卡片,并在原地实时更新 thinking、正文和工具摘要。
100
100
  卡片会读取 JSONL 的 `timestamp`,以本地时间 `YYYY-MM-DD HH:mm` 显示 thinking、正文、tool call/result 和会话历史事件。
@@ -117,20 +117,21 @@ bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYY
117
117
  | `/status` | `/s` | 当前目录、session、模型、进程状态 |
118
118
  | `/stop` | `/t` | 终止当前 agent 进程(SIGKILL) |
119
119
  | `/ps` | - | 是否有进程在跑 |
120
- | `/restart` | - | 原地自重启 bridge(新进程同 config 接管) |
120
+ | `/restart` | - | 原地自重启 lark-remote(新进程同 config 接管) |
121
121
  | `/clone [name]` | - | 复制分身:克隆当前配置到新目录并扫码创建新飞书应用,完成后自动绑定、同步状态并拉起新实例 |
122
122
  | `/config get\|set` | `/c` | 查改运行时配置(agent-aware 卡片) |
123
123
  | `/order save\|list\|edit\|alias` | `/o` | 收藏常用指令;`/order edit` 修改指令文本(保留别名/使用统计);`/order alias` 注册快捷别名(输入 `$name` 展开) |
124
124
  | `!<cmd>` | - | 执行 bash 命令并流式输出到卡片(绕过串行队列) |
125
- | `/exit` | `/e` | 退出 bridge |
125
+ | `/exit` | `/e` | 退出 lark-remote |
126
126
 
127
127
  直接发非 `/` 开头的消息即转发给当前默认 agent。
128
128
 
129
- **复制分身(`/clone`)**:想在另一个项目/机器目录旁跑一个独立 bridge 实例时,发 `/clone 名字` 即可——当前配置目录会被克隆为 `<configDir>-<名字>`(除飞书凭据外全部复制),二维码以图片消息发进私聊引导你扫码创建一个全新飞书应用;扫码成功后自动完成绑定,workspace 别名、常用指令、工作目录与会话状态一并迁移,新实例自动拉起并可直接续聊。也可直接点 `/help` 卡片上的「/clone」按钮。详见 [docs/zh/usage.md](docs/zh/usage.md)。
129
+ **复制分身(`/clone`)**:想在另一个项目/机器目录旁跑一个独立 lark-remote 实例时,发 `/clone 名字` 即可——当前配置目录会被克隆为 `<configDir>-<名字>`(除飞书凭据外全部复制),二维码以图片消息发进私聊引导你扫码创建一个全新飞书应用;扫码成功后自动完成绑定,workspace 别名、常用指令、工作目录与会话状态一并迁移,新实例自动拉起并可直接续聊。也可直接点 `/help` 卡片上的「/clone」按钮。详见 [docs/zh/usage.md](docs/zh/usage.md)。
130
130
 
131
131
  往飞书私聊发**图片/文件**会自动保存到当前目录的 `.lark-remote-temp/<YYYYMMDDHHmm>/`
132
- (建议把 `.lark-remote-temp/` 加进项目 `.gitignore`),随后说「请处理刚才保存的文件」
133
- 即可让 agent 直接读本地文件。更多用法见 [docs/zh/usage.md](docs/zh/usage.md)。
132
+ (建议把 `.lark-remote-temp/` 加进项目 `.gitignore`),保存提示里会带上目录完整路径,
133
+ 随后说「请处理刚才保存的文件」(或直接把提示转给 agent)即可让 agent 直接读本地文件。
134
+ 更多用法见 [docs/zh/usage.md](docs/zh/usage.md)。
134
135
 
135
136
  ## 测试
136
137
 
package/dist/binder.d.ts CHANGED
@@ -10,7 +10,7 @@ type BindDecision = {
10
10
  /**
11
11
  * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
12
12
  *
13
- * 设计依据:bridge 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
13
+ * 设计依据:lark-remote 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
14
14
  * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
15
15
  * 分享出去属于 owner 的主动行为),因此首次消息即绑定,不再要求输入 PIN。
16
16
  * 本类复用已有 startup-contact.json,不新增配置字段。
package/dist/binder.js CHANGED
@@ -2,7 +2,7 @@ import { getLogger } from './logger/index.js';
2
2
  /**
3
3
  * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
4
4
  *
5
- * 设计依据:bridge 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
5
+ * 设计依据:lark-remote 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
6
6
  * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
7
7
  * 分享出去属于 owner 的主动行为),因此首次消息即绑定,不再要求输入 PIN。
8
8
  * 本类复用已有 startup-contact.json,不新增配置字段。
@@ -26,7 +26,7 @@ export function resolveFinalUsage(registry, defaultAgent, sessionId, cwd, agentK
26
26
  // EnterWorktree relocate (2026-08-04): jsonl read silently returned no
27
27
  // usage (e.g. transcript moved mid-session). Surface it so token-stat
28
28
  // fallback to per-run live usage is visible in logs, not silent.
29
- getLogger().warn(`[bridge] resolveFinalUsage: no usage from jsonl sessionId=${sessionId} cwd=${cwd} agent=${agentKind}, card falls back to per-run live usage`);
29
+ getLogger().warn(`[lark-remote] resolveFinalUsage: no usage from jsonl sessionId=${sessionId} cwd=${cwd} agent=${agentKind}, card falls back to per-run live usage`);
30
30
  }
31
31
  return content.usage
32
32
  ? {
@@ -48,7 +48,7 @@ export function resolveFinalUsage(registry, defaultAgent, sessionId, cwd, agentK
48
48
  : undefined;
49
49
  }
50
50
  catch (err) {
51
- getLogger().warn(`[bridge] resolveFinalUsage failed sessionId=${sessionId}: ${errorMessage(err)}`);
51
+ getLogger().warn(`[lark-remote] resolveFinalUsage failed sessionId=${sessionId}: ${errorMessage(err)}`);
52
52
  return undefined;
53
53
  }
54
54
  }
@@ -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,25 +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
- const text = `📎 已保存 ${batch.saved.length} 个文件:\n` +
270
- lines.join('\n') +
271
- overflow +
272
- failures +
273
- '\n💡 你可以直接说:请处理刚才保存的文件';
274
- await this.deps.send({
275
- userId: batch.payload.userId,
276
- chatId: batch.payload.chatId,
277
- messageId: batch.payload.messageId,
278
- }, text);
279
- }
280
296
  }
281
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.