lark-remote 0.4.4 → 0.5.0

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 (170) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/binder.d.ts +11 -10
  3. package/dist/binder.js +17 -16
  4. package/dist/bridge/approval-coordinator.d.ts +1 -1
  5. package/dist/bridge/approval-coordinator.js +3 -3
  6. package/dist/bridge/inbound-media.d.ts +1 -27
  7. package/dist/bridge/inbound-media.js +23 -21
  8. package/dist/bridge/index.d.ts +57 -57
  9. package/dist/bridge/index.js +136 -129
  10. package/dist/bridge/queue-manager.d.ts +45 -38
  11. package/dist/bridge/queue-manager.js +136 -136
  12. package/dist/card/approval-render.d.ts +1 -1
  13. package/dist/card/approval-render.js +6 -6
  14. package/dist/card/bash-card-session.d.ts +3 -3
  15. package/dist/card/bash-card-session.js +3 -3
  16. package/dist/card/bash-renderer.js +5 -5
  17. package/dist/card/card-session.d.ts +4 -4
  18. package/dist/card/card-session.js +6 -6
  19. package/dist/card/card-shared.d.ts +1 -1
  20. package/dist/card/card-shared.js +2 -2
  21. package/dist/card/run-card-session.js +2 -2
  22. package/dist/card/run-renderer.d.ts +1 -1
  23. package/dist/card/run-renderer.js +22 -22
  24. package/dist/card/run-state.d.ts +2 -2
  25. package/dist/card/run-state.js +3 -3
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +6 -6
  28. package/dist/cli.js +5 -6
  29. package/dist/clone.d.ts +6 -9
  30. package/dist/clone.js +12 -8
  31. package/dist/common/clamp.d.ts +2 -0
  32. package/dist/common/clamp.js +5 -0
  33. package/dist/common/fs.d.ts +4 -3
  34. package/dist/common/fs.js +5 -4
  35. package/dist/common/truncate.d.ts +1 -1
  36. package/dist/common/truncate.js +2 -2
  37. package/dist/common/ttl-cache.d.ts +2 -2
  38. package/dist/common/ttl-cache.js +2 -2
  39. package/dist/config/codex-config.d.ts +9 -9
  40. package/dist/config/codex-config.js +18 -18
  41. package/dist/config/dir.d.ts +4 -5
  42. package/dist/config/dir.js +12 -10
  43. package/dist/config/index.d.ts +3 -3
  44. package/dist/config/index.js +6 -6
  45. package/dist/config/opencode-config.js +3 -3
  46. package/dist/config/wizard.js +2 -2
  47. package/dist/connector/index.d.ts +5 -7
  48. package/dist/connector/index.js +29 -16
  49. package/dist/inbound/event.d.ts +1 -1
  50. package/dist/inbound/event.js +1 -1
  51. package/dist/inbound/placeholder.d.ts +2 -2
  52. package/dist/inbound/placeholder.js +2 -2
  53. package/dist/inbound/receipt.d.ts +1 -3
  54. package/dist/inbound/receipt.js +2 -2
  55. package/dist/inbound/turn-assembler.d.ts +1 -3
  56. package/dist/inbound/turn-assembler.js +2 -2
  57. package/dist/index.js +38 -30
  58. package/dist/instance-lock.js +8 -7
  59. package/dist/logger/index.d.ts +1 -1
  60. package/dist/logger/index.js +4 -4
  61. package/dist/persistence/atomic-write.d.ts +1 -1
  62. package/dist/persistence/atomic-write.js +9 -5
  63. package/dist/platform/agent-stopper.d.ts +2 -2
  64. package/dist/platform/identity.d.ts +7 -1
  65. package/dist/platform/identity.js +2 -2
  66. package/dist/platform/path.d.ts +6 -0
  67. package/dist/platform/path.js +14 -8
  68. package/dist/platform/select.d.ts +1 -1
  69. package/dist/platform/select.js +1 -1
  70. package/dist/platform/shell.d.ts +2 -3
  71. package/dist/platform/spawn.d.ts +2 -2
  72. package/dist/platform/spawn.js +2 -2
  73. package/dist/platform/terminator-posix.js +6 -6
  74. package/dist/platform/terminator-win32.js +3 -3
  75. package/dist/platform/terminator.d.ts +3 -3
  76. package/dist/platform/terminator.js +2 -2
  77. package/dist/platform/types.d.ts +1 -1
  78. package/dist/restart.d.ts +1 -1
  79. package/dist/restart.js +5 -3
  80. package/dist/router/card-helpers.d.ts +1 -3
  81. package/dist/router/card-helpers.js +2 -2
  82. package/dist/router/config/codex.d.ts +1 -1
  83. package/dist/router/config/codex.js +3 -3
  84. package/dist/router/config/common/model-patch.d.ts +1 -2
  85. package/dist/router/config/common/model-patch.js +1 -2
  86. package/dist/router/config/opencode.js +2 -2
  87. package/dist/router/config/pi.js +1 -1
  88. package/dist/router/index.d.ts +30 -30
  89. package/dist/router/index.js +103 -84
  90. package/dist/router/order-exec-dispatch.d.ts +3 -10
  91. package/dist/router/order-exec-dispatch.js +5 -5
  92. package/dist/router/{utils.d.ts → usage-format.d.ts} +2 -4
  93. package/dist/router/{utils.js → usage-format.js} +3 -8
  94. package/dist/runner/bash/runner.d.ts +2 -2
  95. package/dist/runner/bash/runner.js +6 -6
  96. package/dist/runner/claude/runner.d.ts +1 -1
  97. package/dist/runner/claude/runner.js +2 -3
  98. package/dist/runner/claude/session.d.ts +4 -6
  99. package/dist/runner/claude/session.js +21 -23
  100. package/dist/runner/codex/app-server/runner.js +3 -3
  101. package/dist/runner/codex/app-server/translator.d.ts +2 -2
  102. package/dist/runner/codex/app-server/translator.js +5 -5
  103. package/dist/runner/common/acp/{protocol-helpers.d.ts → acp-approval.d.ts} +4 -10
  104. package/dist/runner/common/acp/{protocol-helpers.js → acp-approval.js} +7 -6
  105. package/dist/runner/common/acp/base-acp-runner.d.ts +1 -1
  106. package/dist/runner/common/acp/base-acp-runner.js +2 -2
  107. package/dist/runner/common/acp/base-translator.d.ts +3 -3
  108. package/dist/runner/common/acp/base-translator.js +4 -4
  109. package/dist/runner/common/acp/protocol-types.js +1 -1
  110. package/dist/runner/common/connection-based-runner.d.ts +5 -5
  111. package/dist/runner/common/connection-based-runner.js +5 -5
  112. package/dist/runner/common/jsonl-stream.d.ts +2 -2
  113. package/dist/runner/common/jsonl-stream.js +12 -12
  114. package/dist/runner/common/jsonrpc/client.d.ts +1 -1
  115. package/dist/runner/common/jsonrpc/client.js +1 -1
  116. package/dist/runner/common/jsonrpc/connection-manager.d.ts +13 -13
  117. package/dist/runner/common/jsonrpc/connection-manager.js +37 -37
  118. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  119. package/dist/runner/common/jsonrpc/transport.js +2 -2
  120. package/dist/runner/common/{usage.d.ts → result-usage.d.ts} +1 -1
  121. package/dist/runner/common/{usage.js → result-usage.js} +2 -2
  122. package/dist/runner/common/spawning-runner.d.ts +23 -10
  123. package/dist/runner/common/spawning-runner.js +48 -22
  124. package/dist/runner/common/{runner-utils.d.ts → synthetic-events.d.ts} +4 -4
  125. package/dist/runner/common/{runner-utils.js → synthetic-events.js} +5 -5
  126. package/dist/runner/dsh/client.d.ts +1 -1
  127. package/dist/runner/dsh/client.js +1 -1
  128. package/dist/runner/dsh/runner.js +3 -3
  129. package/dist/runner/kimi/acp/runner.d.ts +4 -4
  130. package/dist/runner/kimi/acp/runner.js +10 -10
  131. package/dist/runner/kimi/acp/translator.d.ts +2 -2
  132. package/dist/runner/kimi/acp/translator.js +5 -5
  133. package/dist/runner/opencode/acp/runner.d.ts +4 -4
  134. package/dist/runner/opencode/acp/runner.js +4 -4
  135. package/dist/runner/opencode/acp/translator.js +2 -2
  136. package/dist/runner/pi/rpc/runner.d.ts +1 -1
  137. package/dist/runner/pi/rpc/runner.js +2 -2
  138. package/dist/runner/pi/rpc/translator.d.ts +1 -2
  139. package/dist/runner/pi/rpc/translator.js +4 -6
  140. package/dist/runner/registry.d.ts +3 -3
  141. package/dist/runner/registry.js +3 -3
  142. package/dist/runner/types.d.ts +1 -1
  143. package/dist/session/claude/session-index.d.ts +13 -1
  144. package/dist/session/claude/session-index.js +21 -6
  145. package/dist/session/claude/session-reader.d.ts +1 -1
  146. package/dist/session/claude/session-reader.js +1 -1
  147. package/dist/session/claude/sessions.js +10 -10
  148. package/dist/session/codex/rollout-reader.d.ts +4 -4
  149. package/dist/session/codex/rollout-reader.js +14 -14
  150. package/dist/session/common/two-pass.js +2 -2
  151. package/dist/session/common/usage-accumulator.d.ts +2 -2
  152. package/dist/session/common/usage-accumulator.js +2 -2
  153. package/dist/session/dsh/sessions.d.ts +1 -1
  154. package/dist/session/dsh/sessions.js +3 -3
  155. package/dist/session/kimi/sessions.d.ts +2 -6
  156. package/dist/session/kimi/sessions.js +13 -19
  157. package/dist/session/opencode/sessions.d.ts +3 -3
  158. package/dist/session/opencode/sessions.js +15 -13
  159. package/dist/session/pi/sessions.js +13 -12
  160. package/dist/session/registry.d.ts +1 -1
  161. package/dist/session/registry.js +1 -1
  162. package/dist/session/session-store.d.ts +3 -4
  163. package/dist/session/session-store.js +3 -5
  164. package/dist/update/index.d.ts +1 -1
  165. package/dist/update/index.js +1 -1
  166. package/dist/update/version-check.d.ts +5 -3
  167. package/dist/update/version-check.js +10 -8
  168. package/dist/workspace/index.d.ts +4 -4
  169. package/dist/workspace/index.js +5 -5
  170. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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.5.0] - 2026-10-07
9
+
10
+ ### 新增
11
+
12
+ - **CI 增加 Windows 全量验证**:新增 `windows-latest` job(install → typecheck → test → build → `--version`/`--help` 冒烟)。此前只有 ubuntu job,被平台门控跳过的用例在 CI 里是盲区,win32 独有回归只能靠本地发现
13
+
14
+ ### 修复
15
+
16
+ - **一次发多图/多文件不再只保存 1 个**:飞书客户端「一次选多图/多文件」会背靠背秒发成多条独立消息,SDK 层 600ms 去抖把它们合并成一条——只保留最后一条的 `messageId`,却把多条消息的附件求并集。下载侧于是拿同一个 `messageId` 去下所有文件,除最后一条外全部报 234003(HTTP 400)。现关掉 SDK 这层合批(`chatQueue` 串行与卡片动作通道保留),合批交回项目自身的合并窗口
17
+ - **Windows 上会话偶尔「消失」**:按工作目录查会话时,大小写/分隔符/尾斜杠的归一不统一,`C:\Work` 与 `c:/work/` 被当成两个目录,`/resume` 列表会漏列。现统一走同一套判等键
18
+ - **Windows 上含 `<>:"|?*` 或尾点的文件名落盘失败**:入站附件名现在会剥掉 win32 非法字符与尾部点/空格(win32 会静默剥离它们,导致落盘名与记录名不一致)
19
+ - **Windows 上可能误杀其他工作目录的 agent**:pid 文件名此前只按消毒后的目录名区分,`/w/a+b` 与 `/w/a_b` 会撞同一个文件,启动时的孤儿回收可能杀掉对方工作目录里存活的 agent。现后缀掺入原始目录的哈希
20
+ - **`~` 开头的配置目录在 Windows 上不展开**:`~\` 形式此前不识别
21
+
22
+ ### 变更
23
+
24
+ - **注释与测试命名清理**:去掉源码、测试注释与用例名中指向未发布内部文档的引用(评审条目号、场景号、轮次号、文件名前缀),让公开仓库里的引用都能落地。无行为变化
25
+
26
+ ## [0.4.5] - 2026-10-07
27
+
28
+ ### 修复
29
+
30
+ - **消息不再漂移到新会话**:紧跟在文字消息之后点击「新会话」「切换工作目录」「继续会话」等会变更会话的卡片按钮时,此前消息可能落到新会话里执行。现在这类卡片动作会先等消息合并窗口落地(消息仍绑定旧会话/旧目录),再执行动作;同时入队时刻固定会话代际,堵住「已开新会话后又被旧消息把旧会话复活」的漏判
31
+
32
+ ### 变更
33
+
34
+ - **内部清理重构**:删除生产死代码与重复测试脚手架,统一内部命名,无行为变化
35
+
8
36
  ## [0.4.4] - 2026-10-04
9
37
 
10
38
  ### 修复
package/dist/binder.d.ts CHANGED
@@ -8,7 +8,7 @@ type BindDecision = {
8
8
  kind: 'bind_success';
9
9
  };
10
10
  /**
11
- * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
11
+ * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 userId 可用。
12
12
  *
13
13
  * 设计依据:lark-remote 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
14
14
  * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
@@ -17,30 +17,31 @@ type BindDecision = {
17
17
  *
18
18
  * 状态机:
19
19
  * - 未绑定:第一条私聊消息(任意内容)即完成绑定 -> 写入 startup-contact.json。
20
- * - 已绑定:仅 senderId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
20
+ * - 已绑定:仅 userId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
21
21
  *
22
- * cardAction 同样校验 operator.openId === bound.userId(未绑定也视为非 owner)。
22
+ * cardAction 同样校验 operator.openId(飞书 SDK 字段,值即 userId)=== bound.userId;
23
+ * 未绑定也视为非 owner。
23
24
  */
24
25
  export declare class OwnerBinder {
25
26
  private readonly store;
26
27
  private rejectedTotal;
27
28
  constructor(store: StartupContactStore);
28
29
  isBound(): boolean;
29
- /** 已绑定的 owner openId;未绑定时 undefined。 */
30
- boundOpenId(): string | undefined;
31
- /** 卡片操作者是否为已绑定的 owner(未绑定返回 false)。 */
32
- isOwner(openId: string): boolean;
30
+ /** 已绑定的 owner userId;未绑定时 undefined。 */
31
+ boundUserId(): string | undefined;
32
+ /** 用户是否为已绑定的 owner(未绑定返回 false)。 */
33
+ isOwner(userId: string): boolean;
33
34
  /** 累计被拒(非 owner)消息/卡片数,用于 DoS 可观测。 */
34
35
  get rejectedCount(): number;
35
36
  /**
36
37
  * 对入站私聊消息做绑定/授权判定,含副作用:
37
38
  * - `owner`:放行,调用方继续正常处理
38
39
  * - `rejected`:已绑定但非 owner,静默丢弃(计数 + debug)
39
- * - `bind_success`:未绑定收到首条消息,写入绑定(任意内容均可)
40
+ * - `bind_success`:未绑定收到首条消息,写入绑定(任意消息均可)
40
41
  */
41
- classify(senderId: string, content: string, chatId: string): BindDecision;
42
+ authorize(userId: string, chatId: string): BindDecision;
42
43
  /** 卡片操作被拒时计数(与消息路径共用计数器)。 */
43
- recordRejectedCardAction(openId: string): void;
44
+ recordRejectedCardAction(userId: string): void;
44
45
  }
45
46
  /**
46
47
  * 控制台绑定引导文案(首次未绑定时输出到 stderr;守护模式下被 watchdog
package/dist/binder.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getLogger } from './logger/index.js';
2
2
  /**
3
- * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
3
+ * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 userId 可用。
4
4
  *
5
5
  * 设计依据:lark-remote 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
6
6
  * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
@@ -9,9 +9,10 @@ import { getLogger } from './logger/index.js';
9
9
  *
10
10
  * 状态机:
11
11
  * - 未绑定:第一条私聊消息(任意内容)即完成绑定 -> 写入 startup-contact.json。
12
- * - 已绑定:仅 senderId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
12
+ * - 已绑定:仅 userId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
13
13
  *
14
- * cardAction 同样校验 operator.openId === bound.userId(未绑定也视为非 owner)。
14
+ * cardAction 同样校验 operator.openId(飞书 SDK 字段,值即 userId)=== bound.userId;
15
+ * 未绑定也视为非 owner。
15
16
  */
16
17
  export class OwnerBinder {
17
18
  store;
@@ -22,14 +23,14 @@ export class OwnerBinder {
22
23
  isBound() {
23
24
  return this.store.getContact() !== undefined;
24
25
  }
25
- /** 已绑定的 owner openId;未绑定时 undefined。 */
26
- boundOpenId() {
26
+ /** 已绑定的 owner userId;未绑定时 undefined。 */
27
+ boundUserId() {
27
28
  return this.store.getContact()?.userId;
28
29
  }
29
- /** 卡片操作者是否为已绑定的 owner(未绑定返回 false)。 */
30
- isOwner(openId) {
30
+ /** 用户是否为已绑定的 owner(未绑定返回 false)。 */
31
+ isOwner(userId) {
31
32
  const bound = this.store.getContact()?.userId;
32
- return bound !== undefined && bound === openId;
33
+ return bound !== undefined && bound === userId;
33
34
  }
34
35
  /** 累计被拒(非 owner)消息/卡片数,用于 DoS 可观测。 */
35
36
  get rejectedCount() {
@@ -39,26 +40,26 @@ export class OwnerBinder {
39
40
  * 对入站私聊消息做绑定/授权判定,含副作用:
40
41
  * - `owner`:放行,调用方继续正常处理
41
42
  * - `rejected`:已绑定但非 owner,静默丢弃(计数 + debug)
42
- * - `bind_success`:未绑定收到首条消息,写入绑定(任意内容均可)
43
+ * - `bind_success`:未绑定收到首条消息,写入绑定(任意消息均可)
43
44
  */
44
- classify(senderId, content, chatId) {
45
+ authorize(userId, chatId) {
45
46
  const bound = this.store.getContact();
46
47
  if (bound) {
47
- if (senderId === bound.userId)
48
+ if (userId === bound.userId)
48
49
  return { kind: 'owner' };
49
50
  this.rejectedTotal++;
50
- getLogger().debug(`[binder] rejected message from ${senderId} (total rejected=${this.rejectedCount})`);
51
+ getLogger().debug(`[binder] rejected message from ${userId} (total rejected=${this.rejectedCount})`);
51
52
  return { kind: 'rejected' };
52
53
  }
53
54
  // 未绑定:任意内容即绑定(首条消息完成认领)
54
- this.store.save({ chatId, userId: senderId });
55
- getLogger().info(`[binder] owner bound: openId=${senderId} chatId=${chatId}`);
55
+ this.store.save({ chatId, userId });
56
+ getLogger().info(`[binder] owner bound: userId=${userId} chatId=${chatId}`);
56
57
  return { kind: 'bind_success' };
57
58
  }
58
59
  /** 卡片操作被拒时计数(与消息路径共用计数器)。 */
59
- recordRejectedCardAction(openId) {
60
+ recordRejectedCardAction(userId) {
60
61
  this.rejectedTotal++;
61
- getLogger().debug(`[binder] rejected card action from ${openId} (total rejected=${this.rejectedCount})`);
62
+ getLogger().debug(`[binder] rejected card action from ${userId} (total rejected=${this.rejectedCount})`);
62
63
  }
63
64
  }
64
65
  /**
@@ -95,7 +95,7 @@ export declare class ApprovalCoordinator {
95
95
  nonce?: string;
96
96
  }): Promise<void>;
97
97
  /**
98
- * AskUserQuestion 自定义答案(Other,review P3-4):自由文本直接作为该
98
+ * AskUserQuestion 自定义答案(Other):自由文本直接作为该
99
99
  * 单选问题的答案。与单选 toggle 同语义:全部问题答完自动提交。
100
100
  */
101
101
  answerCustom(action: {
@@ -180,7 +180,7 @@ export class ApprovalCoordinator {
180
180
  }
181
181
  const selected = new Set(q.selected ?? []);
182
182
  if (q.multiSelect) {
183
- // review P2-1:多选切换按钮同一 nonce 重复投递(双击/飞书重投递)只应
183
+ // 多选切换按钮同一 nonce 重复投递(双击/飞书重投递)只应
184
184
  // toggle 一次,否则勾选会被二次 toggle 抵消。单选用例的重复防护在
185
185
  // respondQuestionAnswers(提交即幂等,选中是 set 语义)。
186
186
  this.assertFreshNonce(ctx.requestId, ctx.nonce);
@@ -226,7 +226,7 @@ export class ApprovalCoordinator {
226
226
  }
227
227
  }
228
228
  /**
229
- * AskUserQuestion 自定义答案(Other,review P3-4):自由文本直接作为该
229
+ * AskUserQuestion 自定义答案(Other):自由文本直接作为该
230
230
  * 单选问题的答案。与单选 toggle 同语义:全部问题答完自动提交。
231
231
  */
232
232
  async answerCustom(action, ctx) {
@@ -451,7 +451,7 @@ export class ApprovalCoordinator {
451
451
  void this.responder(requestId, { action: 'cancel' }).catch((err) => {
452
452
  getLogger().warn(`[approval-coordinator] expiry cancel failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
453
453
  // 响应失败不是「正常过期」:状态升级为 failed,避免误判为已正常通知
454
- // server(spec R1:responder 抛错时状态置 failed)。
454
+ // server(spec responder 抛错时状态置 failed)。
455
455
  tracked.state = 'failed';
456
456
  // 兜底:cancel 未能送达时主动中断 turn,避免 server 无限等待审批响应。
457
457
  void this.interruptTurn().catch((interruptErr) => {
@@ -1,38 +1,12 @@
1
1
  import { type AppConfig } from '../config/index.js';
2
2
  import type { MediaOutcome } from '../inbound/turn.js';
3
- import type { InboundResourceKind, InboundMediaItem, InboundMediaPayload } from '../connector/index.js';
3
+ import type { InboundMediaPayload } from '../connector/index.js';
4
4
  export interface InboundMediaDeps {
5
5
  /** 解析用户 cwd(bridge.resolveCwd 注入,统一三份复制点)。 */
6
6
  resolveCwd: (userId: string) => string | undefined;
7
7
  /** 读取当前配置(活引用,随 /config 保存更新,避免启动快照过期)。 */
8
8
  getConfig: () => AppConfig;
9
9
  }
10
- /**
11
- * 文件名 sanitize:
12
- * path.basename 剥离目录(防 `../` 穿越),再替换控制字符与路径分隔符;
13
- * 空名 / `.` / `..` 视为无效(返回空串由调用方生成兜底名)。
14
- */
15
- export declare function sanitizeFileName(name: string): string;
16
- /**
17
- * 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
18
- * 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
19
- */
20
- export declare function extensionFor(_kind: InboundResourceKind, mimeType: string | undefined, head: Buffer): string | undefined;
21
- /**
22
- * 文件名长度限制(字节级,保留扩展名),避免 ENAMETOOLONG 整文件失败。
23
- * 极罕见情况(扩展名本身超限)直接截断全名。
24
- */
25
- export declare function limitFileNameLength(name: string, maxBytes?: number): string;
26
- /**
27
- * 生成落盘文件名:
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 都不可识别)。
32
- */
33
- export declare function buildFileName(item: InboundMediaItem, index: number, receivedAt: Date): string;
34
- /** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
35
- export declare function uniqueTargetPath(dir: string, fileName: string): string;
36
10
  export declare class InboundMediaHandler {
37
11
  private readonly deps;
38
12
  constructor(deps: InboundMediaDeps);
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import { DEFAULT_INBOUND_MEDIA_DIR_NAME } from '../config/index.js';
4
4
  import { atomicMoveFile } from '../persistence/atomic-write.js';
5
5
  import { getLogger } from '../logger/index.js';
6
- import { silentlyUnlink } from '../common/fs.js';
6
+ import { bestEffortUnlink } from '../common/fs.js';
7
+ import { truncateUtf8 } from '../common/truncate.js';
7
8
  /**
8
9
  * 入站媒体落盘。
9
10
  *
@@ -52,43 +53,46 @@ function hasControlChar(s) {
52
53
  return false;
53
54
  }
54
55
  /** 子目录时间戳:YYYYMMDDHHmm(本地时间,精确到分钟)。 */
55
- function timeStampDir(d) {
56
+ function timestampDir(d) {
56
57
  return (`${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}` +
57
58
  `${pad2(d.getHours())}${pad2(d.getMinutes())}`);
58
59
  }
59
60
  /** 文件名时间戳:HHmmss(本地时间)。 */
60
- function timeStampHms(d) {
61
+ function timestampHms(d) {
61
62
  return `${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
62
63
  }
63
64
  /**
64
65
  * 文件名 sanitize:
65
- * path.basename 剥离目录(防 `../` 穿越),再替换控制字符与路径分隔符;
66
+ * path.basename 剥离目录(防 `../` 穿越),再替换控制字符、路径分隔符与
67
+ * win32 非法字符(`<>:"|?*`,NTFS/Win32 API 拒绝,落盘直接 EPERM/EINVAL),
68
+ * 并剥掉尾部的点与空格(win32 会静默剥离它们,导致落盘名 ≠ originalName);
66
69
  * 空名 / `.` / `..` 视为无效(返回空串由调用方生成兜底名)。
67
70
  */
68
- export function sanitizeFileName(name) {
71
+ function sanitizeFileName(name) {
69
72
  const base = path.basename(name).trim();
70
73
  const chars = [];
71
74
  for (const ch of base) {
72
75
  const code = ch.charCodeAt(0);
73
- // 控制字符与路径分隔符一律替换为下划线(no-control-regex 不允许字面
74
- // 控制字符转义出现在正则里,逐字符判断语义等价且无 lint 冲突)。
75
- if (code <= 0x1f || code === 0x7f || ch === '/' || ch === '\\') {
76
+ // 控制字符、路径分隔符与 win32 非法字符一律替换为下划线(no-control-regex
77
+ // 不允许字面控制字符转义出现在正则里,逐字符判断语义等价且无 lint 冲突)。
78
+ if (code <= 0x1f || code === 0x7f || '/\\<>:"|?*'.includes(ch)) {
76
79
  chars.push('_');
77
80
  }
78
81
  else {
79
82
  chars.push(ch);
80
83
  }
81
84
  }
82
- const sanitized = chars.join('');
83
- if (sanitized === '' || sanitized === '.' || sanitized === '..')
85
+ // 尾部的点与空格:win32 落盘时被静默剥离,提前剥掉保证记录名与磁盘名一致
86
+ const trimmedEnd = chars.join('').replace(/[. ]+$/, '');
87
+ if (trimmedEnd === '' || trimmedEnd === '.' || trimmedEnd === '..')
84
88
  return '';
85
- return sanitized;
89
+ return trimmedEnd;
86
90
  }
87
91
  /**
88
92
  * 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
89
93
  * 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
90
94
  */
91
- export function extensionFor(_kind, mimeType, head) {
95
+ function extensionFor(_kind, mimeType, head) {
92
96
  const normalized = mimeType?.split(';')[0]?.trim().toLowerCase();
93
97
  if (normalized && MIME_TO_EXT[normalized])
94
98
  return MIME_TO_EXT[normalized];
@@ -137,13 +141,11 @@ export function extensionFor(_kind, mimeType, head) {
137
141
  }
138
142
  return undefined;
139
143
  }
140
- /** 按 UTF-8 字节数截断字符串(避免截在多字节字符中间)。 */
141
- import { truncateUtf8 } from '../common/truncate.js';
142
144
  /**
143
145
  * 文件名长度限制(字节级,保留扩展名),避免 ENAMETOOLONG 整文件失败。
144
146
  * 极罕见情况(扩展名本身超限)直接截断全名。
145
147
  */
146
- export function limitFileNameLength(name, maxBytes = MAX_FILE_NAME_BYTES) {
148
+ function limitFileNameLength(name, maxBytes = MAX_FILE_NAME_BYTES) {
147
149
  if (Buffer.byteLength(name, 'utf8') <= maxBytes)
148
150
  return name;
149
151
  const ext = path.extname(name);
@@ -172,15 +174,15 @@ function readFileHead(filePath, maxBytes = MAGIC_HEAD_BYTES) {
172
174
  * - 无原名的资源:`<kind>_<HHmmss>_<n>.<ext>`,ext 按 MIME/魔数
173
175
  * (旧实现落成无扩展名的 `file_HHmmss_n`,mp4/opus/gif 都不可识别)。
174
176
  */
175
- export function buildFileName(item, index, receivedAt) {
177
+ function buildFileName(item, index, receivedAt) {
176
178
  const original = item.fileName ? limitFileNameLength(sanitizeFileName(item.fileName)) : '';
177
179
  if (item.kind !== 'image' && original)
178
180
  return original;
179
181
  const ext = extensionFor(item.kind, item.mimeType, readFileHead(item.tempPath));
180
- return `${item.kind}_${timeStampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
182
+ return `${item.kind}_${timestampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
181
183
  }
182
184
  /** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
183
- export function uniqueTargetPath(dir, fileName) {
185
+ function uniqueTargetPath(dir, fileName) {
184
186
  const ext = path.extname(fileName);
185
187
  const stem = fileName.slice(0, fileName.length - ext.length);
186
188
  let candidate = path.join(dir, fileName);
@@ -216,7 +218,7 @@ export class InboundMediaHandler {
216
218
  };
217
219
  }
218
220
  const receivedAt = new Date();
219
- const dir = path.join(cwd, this.safeDirName(), timeStampDir(receivedAt));
221
+ const dir = path.join(cwd, this.safeDirName(), timestampDir(receivedAt));
220
222
  try {
221
223
  fs.mkdirSync(dir, { recursive: true });
222
224
  }
@@ -254,7 +256,7 @@ export class InboundMediaHandler {
254
256
  });
255
257
  }
256
258
  catch (err) {
257
- silentlyUnlink(item.tempPath);
259
+ bestEffortUnlink(item.tempPath);
258
260
  const label = item.fileName
259
261
  ? sanitizeFileName(item.fileName) || item.fileName
260
262
  : `第 ${i + 1} 个`;
@@ -287,7 +289,7 @@ export class InboundMediaHandler {
287
289
  /** 清理未被移动的临时文件(无 cwd / 目录创建失败等提前返回路径)。 */
288
290
  cleanupTemps(payload) {
289
291
  for (const item of payload.media) {
290
- silentlyUnlink(item.tempPath);
292
+ bestEffortUnlink(item.tempPath);
291
293
  }
292
294
  }
293
295
  resolveCwd(userId) {