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
@@ -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 {};
@@ -52,6 +52,9 @@ export function parseCliArgs(args = process.argv.slice(2)) {
52
52
  else if (arg === '--dev') {
53
53
  result.dev = true;
54
54
  }
55
+ else if (arg === '--advance-help') {
56
+ result.advanceHelp = true;
57
+ }
55
58
  else if (arg === '--update' || arg === 'update') {
56
59
  // 裸子命令等价形式:`lark-remote update` 与 `--update` 同义。
57
60
  // 不识别时它会落到守护进程路径去抢单例锁,被正在运行的实例挡下
@@ -91,9 +94,75 @@ export function printHelp() {
91
94
  ' --settings <path> 指定 Claude 配置文件路径',
92
95
  ' --dev 开发模式:标记从源码 bun src/index.ts 启动(看门狗据此选择拉起方式)',
93
96
  ' --update 升级到最新版本后退出(用于 cron/脚本自动化升级)',
97
+ ' --advance-help 高级配置参考:config.yaml 全部高级字段的语义与改法(面向 coding agent)',
94
98
  ' -h, --help 显示本帮助信息',
95
99
  ' -v, --version 显示版本号',
96
100
  ];
97
101
  process.stdout.write(lines.join('\n') + '\n');
98
102
  }
103
+ /**
104
+ * Print advanced-config reference to stdout (CLI `--advance-help`).
105
+ *
106
+ * 目标读者是 coding agent(用户让 agent 代改 config.yaml):披露所有
107
+ * /config 卡片未暴露的 YAML 字段——名称、默认值、语义、改法。飞书侧
108
+ * 等价命令为 /advancehelp(内容同源,卡片渲染)。
109
+ */
110
+ export function printAdvanceHelp() {
111
+ const configDir = resolveConfigDir(parseCliArgs().configDir);
112
+ const configPath = configDir + '/config.yaml';
113
+ const lines = [
114
+ 'lark-remote 高级配置参考(--advance-help)',
115
+ '',
116
+ '目标读者:coding agent。用户把本输出交给 agent,agent 据此代改配置。',
117
+ '',
118
+ '配置文件:' + configPath,
119
+ '',
120
+ '修改方式(三选一):',
121
+ ' 1. 直接编辑上面的 config.yaml,改完由用户发送 /restart 生效',
122
+ ' 2. 用户发送 /config <key> <value> 文本直写(立盘即生效),',
123
+ ' 如 /config claude.approvalTimeoutMs 300000',
124
+ ' 3. 常用项走 /config 卡片交互(模型/审批档位/超时等已暴露,不在此列)',
125
+ '',
126
+ 'Key 映射规则:pi./codex./opencode./kimi./dsh. 开头自动映射到 agents.<agent>.<字段>;',
127
+ 'claude. 开头在顶层;其余原样。',
128
+ '',
129
+ 'Claude(顶层 claude.*):',
130
+ ' approvalTimeoutMs (默认 300000) 审批请求超时(ms),超时自动 cancel;0=立即过期;勿随意改短',
131
+ ' idleTtlMinutes (默认 30) 会话空闲回收(分钟),turn 间无新消息超窗回收长驻进程;0=不回收',
132
+ ' stopGraceMs (默认 5000) SIGTERM→SIGKILL 宽限(ms),内部实现细节,勿改',
133
+ '',
134
+ 'Codex(agents.codex.appServer.*):',
135
+ ' binary (默认 codex) codex 二进制路径',
136
+ ' requestTimeoutMs (默认 60000) 单次 RPC 请求超时(ms)',
137
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
138
+ ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
139
+ '',
140
+ 'Kimi(agents.kimi.acp.*):',
141
+ ' binary (默认 kimi) kimi 二进制路径',
142
+ ' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
143
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
144
+ ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
145
+ '',
146
+ 'OpenCode(agents.opencode.acp.*):',
147
+ ' binary (默认 opencode) opencode 二进制路径',
148
+ ' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
149
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
150
+ ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
151
+ '',
152
+ 'Pi:',
153
+ ' agents.pi.tools (默认 read,bash,edit,write,grep,find,ls) 逗号分隔工具白名单',
154
+ '',
155
+ '入站媒体(inboundMedia.*):',
156
+ ' enabled (默认 true) 图片/文件落盘开关',
157
+ ' dirName (默认 .lark-remote-temp) 落盘目录名(位于当前 cwd 下)',
158
+ ' maxFileSizeMb (默认 100) 单文件上限(MiB);飞书 >100MB 需分片,实际不可用更大值',
159
+ '',
160
+ '其他:',
161
+ ' checkUpdateOnStartup (默认 false) 启动时检查更新',
162
+ '',
163
+ '字段全集与校验规则见 Zod schema(src/config/index.ts,代码内唯一事实源)。',
164
+ '直接改 YAML 后必须 /restart;/config 文本直写与卡片保存即时生效。',
165
+ ];
166
+ process.stdout.write(lines.join('\n') + '\n');
167
+ }
99
168
  //# sourceMappingURL=dir.js.map
@@ -20,9 +20,16 @@ export declare const PI_THINKING_LEVELS: readonly ["off", "minimal", "low", "med
20
20
  export declare const DEFAULT_STOP_GRACE_MS: 5000;
21
21
  /** Codex app-server turn idle timeout default (minutes). */
22
22
  export declare const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES: 30;
23
- /** 入站媒体默认配置(schema 默认与 connector 兜底共用,避免双源漂移)。 */
23
+ /**
24
+ * 入站媒体默认配置(schema 默认与 connector 兜底共用,避免双源漂移)。
25
+ *
26
+ * 单文件上限默认 100MB:50MB 对视频偏小(2026-09-15 事故就是 mp4 被丢弃)。
27
+ * 注意飞书侧 `type=file` 超过 100MB 必须用 Range 分片下载,而 SDK 的
28
+ * `downloadResourceToFile` 不分片(必报 234037),所以 100 是实际可用上限
29
+ * (inbound-message-matrix.md §4.4)。
30
+ */
24
31
  export declare const DEFAULT_INBOUND_MEDIA_DIR_NAME = ".lark-remote-temp";
25
- export declare const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB = 50;
32
+ export declare const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB = 100;
26
33
  /** 模型 ID → alias 映射。 */
27
34
  export declare const MODEL_ID_TO_ALIAS: Record<string, string>;
28
35
  /** alias → model ID 反向映射。 */
@@ -71,7 +78,7 @@ export declare const CodexConfigSchema: z.ZodObject<{
71
78
  turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
72
79
  }, z.core.$strip>>;
73
80
  }, z.core.$strip>;
74
- /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
81
+ /** OpenCode-specific configuration (pure ACP mode: `opencode acp` over stdio). */
75
82
  declare const OpencodeConfigSchema: z.ZodObject<{
76
83
  providerID: z.ZodDefault<z.ZodString>;
77
84
  modelID: z.ZodDefault<z.ZodString>;
@@ -79,6 +86,12 @@ declare const OpencodeConfigSchema: z.ZodObject<{
79
86
  plan: "plan";
80
87
  build: "build";
81
88
  }>>;
89
+ acp: z.ZodOptional<z.ZodObject<{
90
+ binary: z.ZodDefault<z.ZodString>;
91
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
92
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
93
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
94
+ }, z.core.$strip>>;
82
95
  }, z.core.$strip>;
83
96
  /** Pi-specific configuration. pi is a spawn-per-message CLI like Claude. */
84
97
  declare const PiConfigSchema: z.ZodObject<{
@@ -179,6 +192,12 @@ export declare const AppConfigSchema: z.ZodObject<{
179
192
  plan: "plan";
180
193
  build: "build";
181
194
  }>>;
195
+ acp: z.ZodOptional<z.ZodObject<{
196
+ binary: z.ZodDefault<z.ZodString>;
197
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
198
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
199
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
200
+ }, z.core.$strip>>;
182
201
  }, z.core.$strip>>;
183
202
  pi: z.ZodOptional<z.ZodObject<{
184
203
  provider: z.ZodDefault<z.ZodString>;
@@ -48,9 +48,16 @@ const DEFAULTS = {
48
48
  export const DEFAULT_STOP_GRACE_MS = DEFAULTS.STOP_GRACE_MS;
49
49
  /** Codex app-server turn idle timeout default (minutes). */
50
50
  export const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES = DEFAULTS.TURN_IDLE_TIMEOUT_MINUTES;
51
- /** 入站媒体默认配置(schema 默认与 connector 兜底共用,避免双源漂移)。 */
51
+ /**
52
+ * 入站媒体默认配置(schema 默认与 connector 兜底共用,避免双源漂移)。
53
+ *
54
+ * 单文件上限默认 100MB:50MB 对视频偏小(2026-09-15 事故就是 mp4 被丢弃)。
55
+ * 注意飞书侧 `type=file` 超过 100MB 必须用 Range 分片下载,而 SDK 的
56
+ * `downloadResourceToFile` 不分片(必报 234037),所以 100 是实际可用上限
57
+ * (inbound-message-matrix.md §4.4)。
58
+ */
52
59
  export const DEFAULT_INBOUND_MEDIA_DIR_NAME = '.lark-remote-temp';
53
- export const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB = 50;
60
+ export const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB = 100;
54
61
  /** 模型 ID → alias 映射。 */
55
62
  export const MODEL_ID_TO_ALIAS = {
56
63
  'claude-opus-4-8': 'opus',
@@ -119,7 +126,7 @@ export const CodexConfigSchema = z.object({
119
126
  /** App-server connection configuration. */
120
127
  appServer: CodexAppServerConfigSchema.optional(),
121
128
  });
122
- /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
129
+ /** OpenCode-specific configuration (pure ACP mode: `opencode acp` over stdio). */
123
130
  const OpencodeConfigSchema = z.object({
124
131
  /** Provider ID for the LLM backend (e.g. 'anthropic'). */
125
132
  providerID: z.string().default('anthropic'),
@@ -127,6 +134,21 @@ const OpencodeConfigSchema = z.object({
127
134
  modelID: z.string().default('claude-sonnet-4-20250514'),
128
135
  /** Session mode (opencode agent name): 'build' (default) or 'plan'. */
129
136
  mode: z.enum(['build', 'plan']).default('build'),
137
+ /** ACP sub-configuration. Structurally mirrors kimi: the card exposes
138
+ * turnIdleTimeoutMinutes; binary/requestTimeoutMs/idleTtlMs are YAML-only
139
+ * engineering params. */
140
+ acp: z
141
+ .object({
142
+ /** Path to opencode binary for ACP mode. */
143
+ binary: z.string().default('opencode'),
144
+ /** Request timeout in milliseconds. */
145
+ requestTimeoutMs: z.number().int().min(0).default(60000),
146
+ /** Idle TTL for ACP connection in milliseconds. */
147
+ idleTtlMs: z.number().int().min(0).default(1800000),
148
+ /** Turn idle timeout in minutes (0 disables). */
149
+ turnIdleTimeoutMinutes: z.number().int().min(0).default(DEFAULT_TURN_IDLE_TIMEOUT_MINUTES),
150
+ })
151
+ .optional(),
130
152
  });
131
153
  /** Pi-specific configuration. pi is a spawn-per-message CLI like Claude. */
132
154
  const PiConfigSchema = z.object({
@@ -256,7 +278,7 @@ export const AppConfigSchema = z.object({
256
278
  inboundMedia: InboundMediaConfigSchema.default(InboundMediaConfigSchema.parse({})),
257
279
  /** Default agent for new runs. */
258
280
  defaultAgent: z.enum(['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh']).default('claude'),
259
- /** Check for updates on bridge startup (default: false). */
281
+ /** Check for updates on lark-remote startup (default: false). */
260
282
  checkUpdateOnStartup: z.boolean().default(false),
261
283
  });
262
284
  const TEMPLATE = `feishu:
@@ -25,6 +25,10 @@ interface FeishuMessage {
25
25
  messageId: string;
26
26
  chatId: string;
27
27
  content: string;
28
+ /** 飞书原始 msg_type(`text` / `image` / `post` / `merge_forward` …)。 */
29
+ rawContentType: string;
30
+ /** 引用消息 id(回复某条消息时存在)。 */
31
+ replyToMessageId?: string;
28
32
  }
29
33
  type MessageHandler = (msg: FeishuMessage) => void;
30
34
  /**
@@ -33,12 +37,19 @@ type MessageHandler = (msg: FeishuMessage) => void;
33
37
  * (bridge 负责把这些失败提示回用户)。
34
38
  */
35
39
  export interface InboundMediaItem {
36
- /** 资源类型:image 消息按 MIME 生成 image_<HHmmss>_<n>.<ext>,file 保留文件名。 */
40
+ /**
41
+ * 下载 type(飞书 `im.v1.messageResource.get` 只认 image/file 两个值,
42
+ * 见 inbound-message-matrix.md §4 —— 不要扩展成 video/audio/sticker)。
43
+ */
37
44
  type: 'image' | 'file';
45
+ /** 资源种类(image/file/video/audio/sticker):命名/展示语义,与下载 type 分离。 */
46
+ kind: InboundResourceKind;
38
47
  /** 原始文件名(file 消息有;image 消息无,由 bridge 按 MIME 生成)。 */
39
48
  fileName?: string;
40
49
  /** 服务端 content-type(参数已剥离;可能缺失,bridge 有兜底)。 */
41
50
  mimeType?: string;
51
+ /** 视频/语音时长(毫秒),透传给 prompt 附件块。 */
52
+ durationMs?: number;
42
53
  /**
43
54
  * 下载内容所在临时文件(流式落盘,避免大文件全量进堆内存)。
44
55
  * 由 bridge 移动到最终位置;任何未移动路径都必须清理该文件。
@@ -46,6 +57,8 @@ export interface InboundMediaItem {
46
57
  tempPath: string;
47
58
  }
48
59
  export interface InboundMediaFailure {
60
+ /** 资源种类(排障/回执分组用)。 */
61
+ kind?: InboundResourceKind;
49
62
  fileName?: string;
50
63
  reason: string;
51
64
  }
@@ -62,16 +75,24 @@ export interface InboundMediaPayload {
62
75
  * 媒体消息到达的轻量描述(未下载):供上层先做 owner/配置闸门,
63
76
  * 通过后再调 downloadInboundMedia —— 下载发生在认证之后(P1 review 修复)。
64
77
  */
78
+ /** SDK 给出的可下载资源种类(`resources[].type`)。 */
79
+ export type InboundResourceKind = 'image' | 'file' | 'audio' | 'video' | 'sticker';
65
80
  export interface InboundMediaMessage {
66
81
  userId: string;
67
82
  chatId: string;
68
83
  messageId: string;
84
+ /** 飞书原始 msg_type(未识别类型时用于 warn,default-deny 而非 default-text)。 */
85
+ rawContentType: string;
69
86
  /** 引用上下文:不同 replyTo 强制拆批。 */
70
87
  replyToMessageId?: string;
71
88
  resources: Array<{
89
+ /** 飞书下载接口 type(只有 image/file 两值)。 */
72
90
  type: 'image' | 'file';
91
+ /** 资源种类(透传 SDK 的 `resources[].type`)。 */
92
+ kind: InboundResourceKind;
73
93
  fileKey: string;
74
94
  fileName?: string;
95
+ durationMs?: number;
75
96
  }>;
76
97
  }
77
98
  type InboundMediaDetectedHandler = (msg: InboundMediaMessage) => void | Promise<void>;
@@ -82,6 +103,12 @@ type InboundMediaDetectedHandler = (msg: InboundMediaMessage) => void | Promise<
82
103
  * means "no immediate response" (original behavior).
83
104
  */
84
105
  type CardActionHandler = (action: CardActionEvent) => void | CardActionResponse | Promise<void | CardActionResponse>;
106
+ /**
107
+ * 卡片 patch 的重试上限。含义:1 次原始尝试 + 最多 PATCH_MAX_RETRIES 次重试。
108
+ */
109
+ export declare const PATCH_MAX_RETRIES = 3;
110
+ /** 重试退避基数(ms);第 n 次重试前等待 n × 基数,给对端恢复的时间。 */
111
+ export declare const PATCH_RETRY_BASE_DELAY_MS = 150;
85
112
  export declare class FeishuConnector {
86
113
  /** 飞书 channel 实例(public:测试直接访问/注入 mock,替代 as unknown as)。 */
87
114
  channel: LarkChannel;
@@ -2,7 +2,9 @@ import { createLarkChannel, } from '@larksuite/channel';
2
2
  import { getLogger } from '../logger/index.js';
3
3
  import { MAX_FILE_UPLOAD_SIZE } from './file-limits.js';
4
4
  import { DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB } from '../config/index.js';
5
+ import { stripPlaceholders } from '../inbound/placeholder.js';
5
6
  import { sleep } from '../common/sleep.js';
7
+ import { isTransientTransportError } from '../error-classification.js';
6
8
  import axios from 'axios';
7
9
  import fs from 'node:fs';
8
10
  import { silentlyUnlink } from '../common/fs.js';
@@ -44,8 +46,45 @@ const noKeepAliveAgent = new https.Agent({ keepAlive: false });
44
46
  * 详见 design.md §9.8。原地更新逻辑新增按钮时参考该节去重风险分类。
45
47
  */
46
48
  export const DEDUP_TTL_MS = 300;
49
+ /**
50
+ * SDK converter 注册表支持的 msg_type(@larksuite/channel 0.3.0 实测)。
51
+ * 带资源但类型不在其中 = 未来新增类型:照常下载 + warn(default-deny)。
52
+ */
53
+ const KNOWN_RESOURCE_CONTENT_TYPES = new Set([
54
+ 'text',
55
+ 'post',
56
+ 'image',
57
+ 'file',
58
+ 'audio',
59
+ 'video',
60
+ 'media',
61
+ 'sticker',
62
+ 'interactive',
63
+ 'merge_forward',
64
+ 'share_chat',
65
+ 'share_user',
66
+ 'location',
67
+ 'system',
68
+ 'vote',
69
+ 'todo',
70
+ 'calendar',
71
+ 'general_calendar',
72
+ 'share_calendar_event',
73
+ 'folder',
74
+ 'hongbao',
75
+ 'video_chat',
76
+ ]);
47
77
  /** 单资源下载超时(毫秒):SDK 无内置超时,挂起会导致提示丢失 + 残留临时文件。 */
48
78
  const RESOURCE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
79
+ /**
80
+ * 合并转发(merge_forward)的聚合资源缺少 `message_id`,飞书要求 key 与 message_id
81
+ * 同属一条消息(234003),且合并转发的资源下载本身被列为不支持(234043)。
82
+ * 自建子消息遍历需要真机验证(inbound-message-matrix.md §5 Step 3),
83
+ * 在验证完成前下载必然失败的场景给用户可执行的替代方案。
84
+ */
85
+ const MERGE_FORWARD_HINT = {
86
+ merge_forward: '(合并转发里的附件暂不支持保存,可逐条转发或直接发送文件)',
87
+ };
49
88
  function withTimeout(promise, ms, label) {
50
89
  return new Promise((resolve, reject) => {
51
90
  const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
@@ -88,6 +127,72 @@ function shouldRetrySendError(err) {
88
127
  return true;
89
128
  return false;
90
129
  }
130
+ /**
131
+ * 卡片 patch 的重试上限。含义:1 次原始尝试 + 最多 PATCH_MAX_RETRIES 次重试。
132
+ */
133
+ export const PATCH_MAX_RETRIES = 3;
134
+ /** 重试退避基数(ms);第 n 次重试前等待 n × 基数,给对端恢复的时间。 */
135
+ export const PATCH_RETRY_BASE_DELAY_MS = 150;
136
+ /** 记日志用的错误摘要(不打印 axios 巨型循环对象)。 */
137
+ function formatErrorForLog(err) {
138
+ if (err instanceof Error) {
139
+ // For axios errors, extract useful info without circular refs
140
+ const axiosErr = err;
141
+ if (axiosErr.response?.status) {
142
+ return `${err.message} (HTTP ${axiosErr.response.status})`;
143
+ }
144
+ if (axiosErr.code) {
145
+ return `${err.message} (code: ${axiosErr.code})`;
146
+ }
147
+ return err.message;
148
+ }
149
+ return String(err);
150
+ }
151
+ /**
152
+ * 卡片 patch 是否值得重试:传输层瞬态失败 + 5xx。
153
+ * 4xx(业务拒绝/请求构造错误)不重试 —— 重试只会得到同样的结果。
154
+ */
155
+ function isRetryablePatchError(err) {
156
+ if (isTransientTransportError(err))
157
+ return true;
158
+ if (err == null || typeof err !== 'object')
159
+ return false;
160
+ const status = err.response?.status;
161
+ return typeof status === 'number' && status >= 500 && status < 600;
162
+ }
163
+ /**
164
+ * 带重试的 `im.v1.message.patch`。
165
+ *
166
+ * 为什么必须在这一层重试:流式卡片的 patch 由 @larksuite/channel 的
167
+ * CardStreamController 经 throttle 延迟触发 —— `controller.update()` 调完
168
+ * `throttle.note()` 就 resolve,不等真正的 patch。失败时是一条脱离 await 链的
169
+ * detached rejection,只能冒泡到 `process.unhandledRejection`,调用方的
170
+ * try/catch 根本挡不住。2026-09-15 00:09 的事故正是如此:卡片 patch 时
171
+ * socket 被关(AxiosError `ERR_SOCKET_CLOSED`),整进程退出,正在跑的 run 陪葬。
172
+ *
173
+ * 这里在唯一的底层 patch 出口上兜住:瞬态错误重试最多 PATCH_MAX_RETRIES 次,
174
+ * 全部失败才把最后一个错误抛出去(此时上层 classifyRejection 也会判为
175
+ * recoverable,不会击穿进程)。patch 是整卡替换,重试天然幂等。
176
+ */
177
+ async function patchWithRetry(patchFn, request, options) {
178
+ let lastError;
179
+ for (let attempt = 0; attempt <= PATCH_MAX_RETRIES; attempt += 1) {
180
+ try {
181
+ return await patchFn(request, options);
182
+ }
183
+ catch (err) {
184
+ lastError = err;
185
+ if (attempt === PATCH_MAX_RETRIES || !isRetryablePatchError(err))
186
+ throw err;
187
+ const delayMs = PATCH_RETRY_BASE_DELAY_MS * (attempt + 1);
188
+ getLogger().warn(`[feishu] message.patch transient failure ` +
189
+ `(attempt ${attempt + 1}/${PATCH_MAX_RETRIES + 1}), retrying in ${delayMs}ms: ` +
190
+ `${formatErrorForLog(err)}`);
191
+ await sleep(delayMs);
192
+ }
193
+ }
194
+ throw lastError;
195
+ }
91
196
  /**
92
197
  * Safely extract the im.v1.message.patch service from a LarkChannel.
93
198
  * Returns undefined when the channel mock omits rawClient (unit tests).
@@ -126,16 +231,19 @@ export class FeishuConnector {
126
231
  },
127
232
  },
128
233
  });
129
- // 观测探针(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
130
- // 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
131
- // 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里只观测不改行为——
132
- // 不 throw、不重试,返回值原样透传。
234
+ // 卡片 patch 的统一出口(观测 + 重试):
235
+ // 1) 观测(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
236
+ // 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
237
+ // 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里把业务码记 warn,
238
+ // 返回值原样透传。
239
+ // 2) 重试(2026-09-15 socket-close 事故):瞬态传输失败就地重试,最多
240
+ // PATCH_MAX_RETRIES 次,不把失败直接落到 unhandledRejection 打死进程。
133
241
  // Guard: unit-test mocks may omit rawClient; skip probe installation in that case.
134
242
  const messageService = tryGetPatchService(this.channel);
135
243
  if (messageService?.patch) {
136
244
  const origPatch = messageService.patch.bind(messageService);
137
245
  messageService.patch = (async (request, options) => {
138
- const res = await origPatch(request, options);
246
+ const res = await patchWithRetry(origPatch, request, options);
139
247
  if (typeof res?.code === 'number' && res.code !== 0) {
140
248
  const content = request?.data?.content;
141
249
  const bytes = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : undefined;
@@ -148,21 +256,27 @@ export class FeishuConnector {
148
256
  this.channel.on('message', (msg) => {
149
257
  if (msg.chatType !== 'p2p')
150
258
  return;
151
- // 入站图片/文件:不转发文本内容(图片消息的 content 只是占位文案),
152
- // 而是下载资源后交给 bridge 落盘(runner 零改动)。
153
- if ((msg.rawContentType === 'image' || msg.rawContentType === 'file') &&
154
- msg.resources.length > 0) {
155
- // 只上报"媒体到达",不在此下载:owner/配置闸门在 index.ts 里先执行,
259
+ // 判据是「是否携带可下载资源」(`msg.resources`),不是 `msg_type` 白名单:
260
+ // SDK 只为 image/file/audio/video/media/sticker 五类产出非空 resources,
261
+ // 枚举 msg_type 必然漏(2026-09-15 事故:mp4 被当文本转发给 agent)。
262
+ // 一条消息可以**同时**产出资源事件与文本事件(post 既有文字又有图),
263
+ // 由 B3 的装配器合并成同一个 turn。
264
+ const hasResources = msg.resources.length > 0;
265
+ if (hasResources) {
266
+ // 只上报「媒体到达」,不在此下载:owner/配置闸门在 index.ts 里先执行,
156
267
  // 通过后才调用 downloadInboundMedia(避免未认证大文件下载打爆内存)。
157
268
  void this.handleInboundMediaDetected(msg);
158
- return;
159
269
  }
160
- this.onMessage?.({
161
- userId: msg.senderId,
162
- messageId: msg.messageId,
163
- chatId: msg.chatId,
164
- content: msg.content,
165
- });
270
+ if (!hasResources || stripPlaceholders(msg.content).clean !== '') {
271
+ this.onMessage?.({
272
+ userId: msg.senderId,
273
+ messageId: msg.messageId,
274
+ chatId: msg.chatId,
275
+ content: msg.content,
276
+ rawContentType: msg.rawContentType,
277
+ replyToMessageId: msg.replyToMessageId,
278
+ });
279
+ }
166
280
  });
167
281
  this.channel.on('cardAction', (action) => {
168
282
  // Return the handler's response (toast / in-place card update) so the
@@ -202,16 +316,26 @@ export class FeishuConnector {
202
316
  getLogger().warn('[feishu] inbound media received but no detected handler registered');
203
317
  return;
204
318
  }
319
+ // default-deny 但可见:未识别类型(未来 SDK 新增)照常下载,但必须留痕,
320
+ // 否则会重演「静默降级成占位符」的事故。
321
+ if (!KNOWN_RESOURCE_CONTENT_TYPES.has(msg.rawContentType)) {
322
+ getLogger().warn(`[feishu] inbound media with unrecognized msg_type="${msg.rawContentType}" ` +
323
+ `resources=${msg.resources.length}, downloading anyway`);
324
+ }
205
325
  try {
206
326
  await this.onInboundMediaDetected({
207
327
  userId: msg.senderId,
208
328
  chatId: msg.chatId,
209
329
  messageId: msg.messageId,
330
+ rawContentType: msg.rawContentType,
210
331
  replyToMessageId: msg.replyToMessageId,
211
332
  resources: msg.resources.map((r) => ({
333
+ // 下载 type 只有 image/file 两个合法值(传 video/audio/sticker 会 400)。
212
334
  type: r.type === 'image' ? 'image' : 'file',
335
+ kind: r.type,
213
336
  fileKey: r.fileKey,
214
337
  fileName: r.fileName,
338
+ durationMs: r.durationMs,
215
339
  })),
216
340
  });
217
341
  }
@@ -237,23 +361,34 @@ export class FeishuConnector {
237
361
  const { contentType, bytesWritten } = await withTimeout(this.channel.downloadResourceToFile(msg.messageId, res.fileKey, res.type, tmpPath), timeoutMs, `downloadResource fileKey=${res.fileKey}`);
238
362
  if (bytesWritten > maxBytes) {
239
363
  silentlyUnlink(tmpPath);
364
+ const limitMb = maxBytes / (1024 * 1024);
240
365
  failures.push({
366
+ kind: res.kind,
241
367
  fileName: res.fileName,
242
- reason: `超过 ${maxBytes / (1024 * 1024)}MB 大小限制`,
368
+ // 飞书侧 >100MB 必须 Range 分片(SDK 不分片),到上限就是下不动:
369
+ // 提示写清楚,避免用户以为"再放大上限就能存"(matrix §4.4/§2 #11)。
370
+ reason: `超过 ${limitMb}MB 大小限制` +
371
+ (limitMb >= 100 ? '(飞书 >100MB 的文件需分片下载,暂不支持)' : ''),
243
372
  });
244
373
  continue;
245
374
  }
246
375
  media.push({
247
376
  type: res.type,
377
+ kind: res.kind,
248
378
  fileName: res.fileName,
249
379
  mimeType: contentType,
380
+ durationMs: res.durationMs,
250
381
  tempPath: tmpPath,
251
382
  });
252
383
  }
253
384
  catch (err) {
254
385
  silentlyUnlink(tmpPath);
255
386
  getLogger().warn(`[feishu] downloadResource failed fileKey=${res.fileKey} type=${res.type}:`, err.message);
256
- failures.push({ fileName: res.fileName, reason: `下载失败: ${err.message}` });
387
+ failures.push({
388
+ kind: res.kind,
389
+ fileName: res.fileName,
390
+ reason: `下载失败: ${err.message}${MERGE_FORWARD_HINT[msg.rawContentType] ?? ''}`,
391
+ });
257
392
  }
258
393
  }
259
394
  return {
@@ -344,18 +479,7 @@ export class FeishuConnector {
344
479
  }
345
480
  /** Format error for logging without causing circular serialization */
346
481
  formatError(err) {
347
- if (err instanceof Error) {
348
- // For axios errors, extract useful info without circular refs
349
- const axiosErr = err;
350
- if (axiosErr.response?.status) {
351
- return `${err.message} (HTTP ${axiosErr.response.status})`;
352
- }
353
- if (axiosErr.code) {
354
- return `${err.message} (code: ${axiosErr.code})`;
355
- }
356
- return err.message;
357
- }
358
- return String(err);
482
+ return formatErrorForLog(err);
359
483
  }
360
484
  async addReaction(messageId, emoji) {
361
485
  try {
@@ -11,5 +11,12 @@
11
11
  * (recoverable,记日志不 exit)还是「进程状态损坏」(fatal,exit)。
12
12
  */
13
13
  type RejectionVerdict = 'recoverable' | 'fatal';
14
+ /**
15
+ * Whether `reason` is a transient transport failure (connection/socket level).
16
+ *
17
+ * Exported because the card-patch retry path needs the same judgement:
18
+ * one definition, so "retry" and "don't kill the process" never drift apart.
19
+ */
20
+ export declare function isTransientTransportError(reason: unknown): boolean;
14
21
  export declare function classifyRejection(reason: unknown): RejectionVerdict;
15
22
  export {};
@@ -1,3 +1,36 @@
1
+ /**
2
+ * 传输层瞬态错误码(Node errno + axios 包装码)。
3
+ *
4
+ * 这些错误代表"这次请求没送出去/连接断了",重试或忽略都是安全的;
5
+ * 它们不携带任何"程序状态损坏"的信息。典型场景:本地 mitmdump 代理重启
6
+ * (ECONNREFUSED)、Wi-Fi 抖动(ENOTFOUND/EAI_AGAIN)、对端关连接
7
+ * (ECONNRESET/EPIPE/ERR_SOCKET_CLOSED)。
8
+ */
9
+ const TRANSIENT_TRANSPORT_CODES = new Set([
10
+ 'ETIMEDOUT',
11
+ 'ECONNRESET',
12
+ 'ECONNREFUSED',
13
+ 'ECONNABORTED',
14
+ 'EPIPE',
15
+ 'EHOSTUNREACH',
16
+ 'ENETUNREACH',
17
+ 'ENOTFOUND',
18
+ 'EAI_AGAIN',
19
+ 'ERR_SOCKET_CLOSED',
20
+ 'ERR_NETWORK',
21
+ ]);
22
+ /**
23
+ * Whether `reason` is a transient transport failure (connection/socket level).
24
+ *
25
+ * Exported because the card-patch retry path needs the same judgement:
26
+ * one definition, so "retry" and "don't kill the process" never drift apart.
27
+ */
28
+ export function isTransientTransportError(reason) {
29
+ if (reason == null || typeof reason !== 'object')
30
+ return false;
31
+ const code = reason.code;
32
+ return typeof code === 'string' && TRANSIENT_TRANSPORT_CODES.has(code);
33
+ }
1
34
  export function classifyRejection(reason) {
2
35
  // Guard against null/undefined/primitive reasons: the `as` cast below is
3
36
  // TS-only (erased at runtime), so `null.response` would throw a TypeError
@@ -10,7 +43,10 @@ export function classifyRejection(reason) {
10
43
  const status = err.response?.status;
11
44
  if (status !== undefined && status >= 500 && status < 600)
12
45
  return 'recoverable';
13
- if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET')
46
+ // 传输层瞬态失败:连接/socket 级别,与进程状态无关。
47
+ // 2026-09-15 00:09 的崩溃就是这条漏了 —— AxiosError `ERR_SOCKET_CLOSED`
48
+ // (卡片 patch 时 socket 被关)落到 fatal → process.exit(1),正在跑的 agent run 陪葬。
49
+ if (isTransientTransportError(reason))
14
50
  return 'recoverable';
15
51
  // 飞书 API 业务错误(如 230027 外部会话无权限)—— 经 SDK throttle detach 路径逃逸的
16
52
  // patch rejection。流式卡片是尽力而为的展示层,飞书侧业务拒绝不应击穿进程。