chenbhao commited on
Commit
1f21206
·
1 Parent(s): 7f4aa7a

feat: desktop

Browse files

cd desktop && bun run tauri dev

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +6 -0
  2. adapters/README.md +101 -0
  3. adapters/bun.lock +150 -0
  4. adapters/common/__tests__/chat-queue.test.ts +61 -0
  5. adapters/common/__tests__/config.test.ts +113 -0
  6. adapters/common/__tests__/format.test.ts +210 -0
  7. adapters/common/__tests__/http-client.test.ts +147 -0
  8. adapters/common/__tests__/message-buffer.test.ts +117 -0
  9. adapters/common/__tests__/message-dedup.test.ts +57 -0
  10. adapters/common/__tests__/pairing.test.ts +22 -0
  11. adapters/common/__tests__/permission.test.ts +43 -0
  12. adapters/common/__tests__/session-store.test.ts +97 -0
  13. adapters/common/__tests__/ws-bridge.test.ts +210 -0
  14. adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  15. adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  16. adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  17. adapters/common/attachment/attachment-limits.ts +58 -0
  18. adapters/common/attachment/attachment-store.ts +121 -0
  19. adapters/common/attachment/attachment-types.ts +29 -0
  20. adapters/common/attachment/image-block-watcher.ts +94 -0
  21. adapters/common/chat-queue.ts +24 -0
  22. adapters/common/config.ts +184 -0
  23. adapters/common/format.ts +323 -0
  24. adapters/common/http-client.ts +207 -0
  25. adapters/common/message-buffer.ts +100 -0
  26. adapters/common/message-dedup.ts +57 -0
  27. adapters/common/pairing.ts +150 -0
  28. adapters/common/permission.ts +70 -0
  29. adapters/common/session-store.ts +82 -0
  30. adapters/common/ws-bridge.ts +289 -0
  31. adapters/dingtalk/__tests__/ai-card.test.ts +101 -0
  32. adapters/dingtalk/__tests__/helpers.test.ts +58 -0
  33. adapters/dingtalk/__tests__/permission-card.test.ts +47 -0
  34. adapters/dingtalk/__tests__/stream-state.test.ts +78 -0
  35. adapters/dingtalk/ai-card.ts +305 -0
  36. adapters/dingtalk/helpers.ts +113 -0
  37. adapters/dingtalk/index.ts +713 -0
  38. adapters/dingtalk/media.ts +84 -0
  39. adapters/dingtalk/permission-card.ts +135 -0
  40. adapters/dingtalk/stream-state.ts +30 -0
  41. adapters/feishu/__tests__/card-errors.test.ts +194 -0
  42. adapters/feishu/__tests__/cardkit.test.ts +295 -0
  43. adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  44. adapters/feishu/__tests__/feishu.test.ts +899 -0
  45. adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  46. adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  47. adapters/feishu/__tests__/media.test.ts +120 -0
  48. adapters/feishu/__tests__/streaming-card.test.ts +947 -0
  49. adapters/feishu/card-errors.ts +151 -0
  50. adapters/feishu/cardkit.ts +337 -0
.gitignore CHANGED
@@ -3,3 +3,9 @@ dist/
3
  cli
4
  .codex
5
  VersperClaw
 
 
 
 
 
 
 
3
  cli
4
  .codex
5
  VersperClaw
6
+
7
+ # Desktop (Tauri) build artifacts
8
+ desktop/src-tauri/target/
9
+ desktop/src-tauri/binaries/
10
+ desktop/src-tauri/gen/
11
+ desktop/pnpm-lock.yaml
adapters/README.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Code IM Adapters
2
+
3
+ 当前目录只放 IM Adapter 运行时代码。
4
+
5
+ 用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
6
+
7
+ - `docs/im/index.md`
8
+ - `docs/im/wechat.md`
9
+ - `docs/im/dingtalk.md`
10
+ - `docs/im/telegram.md`
11
+ - `docs/im/feishu.md`
12
+
13
+ ## 当前方案摘要
14
+
15
+ 当前真实链路是:
16
+
17
+ ```text
18
+ Desktop Webapp Settings
19
+ -> /api/adapters
20
+ -> ~/.claude/adapters.json
21
+ -> adapters/<platform>/index.ts
22
+ -> /api/sessions + /ws/:sessionId
23
+ -> Claude Code session
24
+ ```
25
+
26
+ 注意两点:
27
+
28
+ - IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
29
+ - Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run dingtalk`、`bun run telegram` 或 `bun run feishu`
30
+
31
+ ## 快速启动
32
+
33
+ ```bash
34
+ cd adapters
35
+ bun install
36
+ bun run telegram
37
+ # 或
38
+ bun run feishu
39
+ # 或
40
+ bun run wechat
41
+ # 或
42
+ bun run dingtalk
43
+ ```
44
+
45
+ ## 开发
46
+
47
+ ### 运行测试
48
+
49
+ ```bash
50
+ cd adapters
51
+ bun test
52
+ bun test common/
53
+ bun test telegram/
54
+ bun test feishu/
55
+ bun test wechat/
56
+ bun test dingtalk/
57
+ ```
58
+
59
+ ### 目录结构
60
+
61
+ ```text
62
+ adapters/
63
+ ├── common/
64
+ │ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
65
+ ├── telegram/
66
+ │ └── media.ts # TelegramMediaService(grammy Bot API 封装)
67
+ ├── feishu/
68
+ │ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
69
+ │ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
70
+ ├── wechat/
71
+ │ ├── protocol.ts # 微信 iLink QR 登录 / getupdates / sendmessage 协议封装
72
+ │ └── index.ts # 微信文本聊天 Adapter
73
+ ├── dingtalk/
74
+ │ ├── helpers.ts # 钉钉 Stream 消息解析与会话键
75
+ │ └── index.ts # 钉钉扫码绑定 / Stream 文本聊天 Adapter
76
+ ├── package.json
77
+ ├── tsconfig.json
78
+ └── README.md
79
+ ```
80
+
81
+ ## 附件收发
82
+
83
+ 两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
84
+
85
+ **入站(用户 → Claude):**
86
+
87
+ - 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
88
+ - Telegram: photo、document、video、audio、voice
89
+
90
+ 下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
91
+
92
+ **出站(Claude → 用户):**
93
+
94
+ Agent 流式文本里的 markdown 图片引用 `![alt](path|url|data:)` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
95
+
96
+ - 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
97
+ - Telegram: `bot.api.sendPhoto(InputFile)` 单发
98
+
99
+ 非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
100
+
101
+ 设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
adapters/bun.lock ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "claude-code-im-adapters",
7
+ "dependencies": {
8
+ "@larksuiteoapi/node-sdk": "^1.60.0",
9
+ "dingtalk-stream": "2.1.4",
10
+ "grammy": "^1.42.0",
11
+ "ws": "^8.18.0",
12
+ },
13
+ "devDependencies": {
14
+ "@types/ws": "^8.5.0",
15
+ "bun-types": "latest",
16
+ },
17
+ },
18
+ },
19
+ "overrides": {
20
+ "follow-redirects": "^1.16.0",
21
+ "protobufjs": "^7.5.5",
22
+ },
23
+ "packages": {
24
+ "@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
25
+
26
+ "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="],
27
+
28
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
29
+
30
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
31
+
32
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
33
+
34
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
35
+
36
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
37
+
38
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
39
+
40
+ "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
41
+
42
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
43
+
44
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
45
+
46
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
47
+
48
+ "@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
49
+
50
+ "@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
51
+
52
+ "abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
53
+
54
+ "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
55
+
56
+ "axios": ["axios@1.13.6", "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
57
+
58
+ "bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
59
+
60
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
61
+
62
+ "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
63
+
64
+ "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
65
+
66
+ "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
67
+
68
+ "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
69
+
70
+ "dingtalk-stream": ["dingtalk-stream@2.1.4", "https://registry.npmmirror.com/dingtalk-stream/-/dingtalk-stream-2.1.4.tgz", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="],
71
+
72
+ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
73
+
74
+ "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
75
+
76
+ "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
77
+
78
+ "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
79
+
80
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
81
+
82
+ "event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
83
+
84
+ "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
85
+
86
+ "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
87
+
88
+ "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
89
+
90
+ "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
91
+
92
+ "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
93
+
94
+ "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
95
+
96
+ "grammy": ["grammy@1.42.0", "https://registry.npmmirror.com/grammy/-/grammy-1.42.0.tgz", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="],
97
+
98
+ "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
99
+
100
+ "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
101
+
102
+ "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
103
+
104
+ "lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
105
+
106
+ "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
107
+
108
+ "lodash.pickby": ["lodash.pickby@4.6.0", "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
109
+
110
+ "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
111
+
112
+ "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
113
+
114
+ "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
115
+
116
+ "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
117
+
118
+ "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
119
+
120
+ "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
121
+
122
+ "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
123
+
124
+ "protobufjs": ["protobufjs@7.5.6", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="],
125
+
126
+ "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
127
+
128
+ "qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
129
+
130
+ "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
131
+
132
+ "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
133
+
134
+ "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
135
+
136
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
137
+
138
+ "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
139
+
140
+ "undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
141
+
142
+ "webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
143
+
144
+ "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
145
+
146
+ "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
147
+
148
+ "@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
149
+ }
150
+ }
adapters/common/__tests__/chat-queue.test.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { enqueue } from '../chat-queue.js'
3
+
4
+ describe('ChatQueue', () => {
5
+ it('executes tasks for the same chatId serially', async () => {
6
+ const order: number[] = []
7
+
8
+ await Promise.all([
9
+ enqueue('chat-1', async () => {
10
+ await new Promise((r) => setTimeout(r, 30))
11
+ order.push(1)
12
+ }),
13
+ enqueue('chat-1', async () => {
14
+ order.push(2)
15
+ }),
16
+ enqueue('chat-1', async () => {
17
+ order.push(3)
18
+ }),
19
+ ])
20
+
21
+ // Wait for all to complete
22
+ await new Promise((r) => setTimeout(r, 50))
23
+ expect(order).toEqual([1, 2, 3])
24
+ })
25
+
26
+ it('executes tasks for different chatIds in parallel', async () => {
27
+ const order: string[] = []
28
+
29
+ const p1 = enqueue('chat-a', async () => {
30
+ await new Promise((r) => setTimeout(r, 30))
31
+ order.push('a')
32
+ })
33
+
34
+ const p2 = enqueue('chat-b', async () => {
35
+ order.push('b') // should run immediately, not wait for chat-a
36
+ })
37
+
38
+ await Promise.all([p1, p2])
39
+ await new Promise((r) => setTimeout(r, 50))
40
+
41
+ // 'b' should appear before 'a' since chat-a has a delay
42
+ expect(order[0]).toBe('b')
43
+ expect(order[1]).toBe('a')
44
+ })
45
+
46
+ it('continues processing after a task fails', async () => {
47
+ const order: number[] = []
48
+
49
+ await enqueue('chat-err', async () => {
50
+ order.push(1)
51
+ throw new Error('task failed')
52
+ })
53
+
54
+ await enqueue('chat-err', async () => {
55
+ order.push(2) // should still run
56
+ })
57
+
58
+ await new Promise((r) => setTimeout(r, 20))
59
+ expect(order).toEqual([1, 2])
60
+ })
61
+ })
adapters/common/__tests__/config.test.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import * as fs from 'node:fs'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import { getConfiguredWorkDir, loadConfig } from '../config.js'
6
+
7
+ describe('adapter config defaults', () => {
8
+ const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
9
+ const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
10
+ const originalAdapterDefaultProjectDir = process.env.ADAPTER_DEFAULT_PROJECT_DIR
11
+ const originalDingtalkPermissionCardTemplateId = process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID
12
+ const originalPwd = process.env.PWD
13
+
14
+ afterEach(() => {
15
+ restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
16
+ restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir)
17
+ restoreEnv('ADAPTER_DEFAULT_PROJECT_DIR', originalAdapterDefaultProjectDir)
18
+ restoreEnv('DINGTALK_PERMISSION_CARD_TEMPLATE_ID', originalDingtalkPermissionCardTemplateId)
19
+ restoreEnv('PWD', originalPwd)
20
+ })
21
+
22
+ it('uses the user shell working directory when no default project is configured', () => {
23
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
24
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-'))
25
+ try {
26
+ process.env.CLAUDE_CONFIG_DIR = configDir
27
+ delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
28
+ process.env.PWD = workDir
29
+
30
+ const config = loadConfig()
31
+
32
+ expect(config.telegram.defaultWorkDir).toBe(fs.realpathSync(workDir))
33
+ expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir))
34
+ expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
35
+ expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
36
+ expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir))
37
+ expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(workDir))
38
+ } finally {
39
+ fs.rmSync(configDir, { recursive: true, force: true })
40
+ fs.rmSync(workDir, { recursive: true, force: true })
41
+ }
42
+ })
43
+
44
+ it('keeps the explicit default project ahead of the platform default work dir', () => {
45
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
46
+ const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
47
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-'))
48
+ try {
49
+ fs.writeFileSync(
50
+ path.join(configDir, 'adapters.json'),
51
+ JSON.stringify({ defaultProjectDir }),
52
+ )
53
+ process.env.CLAUDE_CONFIG_DIR = configDir
54
+ process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR = workDir
55
+
56
+ const config = loadConfig()
57
+
58
+ expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir)
59
+ expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(defaultProjectDir)
60
+ expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
61
+ expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
62
+ } finally {
63
+ fs.rmSync(configDir, { recursive: true, force: true })
64
+ fs.rmSync(defaultProjectDir, { recursive: true, force: true })
65
+ fs.rmSync(workDir, { recursive: true, force: true })
66
+ }
67
+ })
68
+
69
+ it('accepts ADAPTER_DEFAULT_PROJECT_DIR as a sidecar-friendly default work dir override', () => {
70
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
71
+ const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
72
+ try {
73
+ process.env.CLAUDE_CONFIG_DIR = configDir
74
+ process.env.ADAPTER_DEFAULT_PROJECT_DIR = defaultProjectDir
75
+ delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
76
+ delete process.env.PWD
77
+
78
+ const config = loadConfig()
79
+
80
+ expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(defaultProjectDir))
81
+ expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(defaultProjectDir))
82
+ } finally {
83
+ fs.rmSync(configDir, { recursive: true, force: true })
84
+ fs.rmSync(defaultProjectDir, { recursive: true, force: true })
85
+ }
86
+ })
87
+
88
+ it('loads DingTalk permission card template id from file or env', () => {
89
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
90
+ try {
91
+ fs.writeFileSync(
92
+ path.join(configDir, 'adapters.json'),
93
+ JSON.stringify({ dingtalk: { permissionCardTemplateId: 'file-template' } }),
94
+ )
95
+ process.env.CLAUDE_CONFIG_DIR = configDir
96
+ delete process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID
97
+ expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('file-template')
98
+
99
+ process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID = 'env-template'
100
+ expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('env-template')
101
+ } finally {
102
+ fs.rmSync(configDir, { recursive: true, force: true })
103
+ }
104
+ })
105
+ })
106
+
107
+ function restoreEnv(key: string, value: string | undefined): void {
108
+ if (value === undefined) {
109
+ delete process.env[key]
110
+ } else {
111
+ process.env[key] = value
112
+ }
113
+ }
adapters/common/__tests__/format.test.ts ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ convertMarkdownTablesToBullets,
4
+ formatImHelp,
5
+ formatImStatus,
6
+ splitMessage,
7
+ formatToolUse,
8
+ formatPermissionRequest,
9
+ truncateInput,
10
+ escapeMarkdownV2,
11
+ } from '../format.js'
12
+
13
+ describe('splitMessage', () => {
14
+ it('returns single chunk for short text', () => {
15
+ expect(splitMessage('hello', 100)).toEqual(['hello'])
16
+ })
17
+
18
+ it('splits at paragraph boundary', () => {
19
+ const text = 'First paragraph.\n\nSecond paragraph.'
20
+ const chunks = splitMessage(text, 20)
21
+ expect(chunks.length).toBeGreaterThan(1)
22
+ expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
23
+ expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
24
+ })
25
+
26
+ it('splits at newline if no paragraph break', () => {
27
+ const text = 'Line one\nLine two\nLine three\nLine four'
28
+ const chunks = splitMessage(text, 20)
29
+ expect(chunks.length).toBeGreaterThan(1)
30
+ })
31
+
32
+ it('hard-splits at limit if no natural break', () => {
33
+ const text = 'a'.repeat(50)
34
+ const chunks = splitMessage(text, 20)
35
+ expect(chunks.length).toBe(3) // 20 + 20 + 10
36
+ expect(chunks.every((c) => c.length <= 20)).toBe(true)
37
+ })
38
+
39
+ it('preserves all content after splitting', () => {
40
+ const text = 'Hello world. This is a test. Foo bar baz.'
41
+ const chunks = splitMessage(text, 15)
42
+ const joined = chunks.join(' ')
43
+ // All words should be present
44
+ expect(joined).toContain('Hello')
45
+ expect(joined).toContain('test')
46
+ expect(joined).toContain('baz')
47
+ })
48
+ })
49
+
50
+ describe('convertMarkdownTablesToBullets', () => {
51
+ it('converts pipe tables into row-labeled bullets', () => {
52
+ const markdown = [
53
+ 'Before',
54
+ '',
55
+ '| Feature | Status | Notes |',
56
+ '| --- | --- | --- |',
57
+ '| Auth | Done | OAuth2 |',
58
+ '| API | WIP | REST only |',
59
+ '',
60
+ 'After',
61
+ ].join('\n')
62
+
63
+ expect(convertMarkdownTablesToBullets(markdown)).toBe([
64
+ 'Before',
65
+ '',
66
+ 'Auth',
67
+ '• Status: Done',
68
+ '• Notes: OAuth2',
69
+ '',
70
+ 'API',
71
+ '• Status: WIP',
72
+ '• Notes: REST only',
73
+ '',
74
+ 'After',
75
+ ].join('\n'))
76
+ })
77
+
78
+ it('skips empty table cells', () => {
79
+ const markdown = [
80
+ '| Item | Value | Notes |',
81
+ '| --- | --- | --- |',
82
+ '| One | 1 | |',
83
+ ].join('\n')
84
+
85
+ expect(convertMarkdownTablesToBullets(markdown)).toBe([
86
+ 'One',
87
+ '• Value: 1',
88
+ ].join('\n'))
89
+ })
90
+
91
+ it('leaves non-table pipe text unchanged', () => {
92
+ const markdown = 'Use foo | bar as plain text.'
93
+ expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
94
+ })
95
+
96
+ it('does not rewrite pipe tables inside fenced code blocks', () => {
97
+ const markdown = [
98
+ '```',
99
+ '| Feature | Status |',
100
+ '| --- | --- |',
101
+ '| Auth | Done |',
102
+ '```',
103
+ ].join('\n')
104
+
105
+ expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
106
+ })
107
+ })
108
+
109
+ describe('formatToolUse', () => {
110
+ it('includes tool name and input preview', () => {
111
+ const result = formatToolUse('Bash', { command: 'npm test' })
112
+ expect(result).toContain('🔧 Bash')
113
+ expect(result).toContain('npm test')
114
+ })
115
+ })
116
+
117
+ describe('formatPermissionRequest', () => {
118
+ it('includes tool name, input preview, and request ID', () => {
119
+ const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
120
+ expect(result).toContain('🔐')
121
+ expect(result).toContain('Bash')
122
+ expect(result).toContain('abcde')
123
+ expect(result).toContain('rm -rf')
124
+ })
125
+ })
126
+
127
+ describe('truncateInput', () => {
128
+ it('returns short input as-is', () => {
129
+ expect(truncateInput('hello', 100)).toBe('hello')
130
+ })
131
+
132
+ it('truncates long input with ellipsis', () => {
133
+ const long = 'x'.repeat(300)
134
+ const result = truncateInput(long, 100)
135
+ expect(result.length).toBe(101) // 100 chars + '…'
136
+ expect(result.endsWith('…')).toBe(true)
137
+ })
138
+
139
+ it('handles objects by stringifying', () => {
140
+ const result = truncateInput({ key: 'value' }, 100)
141
+ expect(result).toContain('key')
142
+ expect(result).toContain('value')
143
+ })
144
+
145
+ it('handles unserializable input', () => {
146
+ const circular: any = {}
147
+ circular.self = circular
148
+ expect(truncateInput(circular, 100)).toBe('(unserializable)')
149
+ })
150
+ })
151
+
152
+ describe('escapeMarkdownV2', () => {
153
+ it('escapes special characters', () => {
154
+ expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
155
+ expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
156
+ expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
157
+ })
158
+
159
+ it('leaves plain text unchanged', () => {
160
+ expect(escapeMarkdownV2('hello world')).toBe('hello world')
161
+ })
162
+ })
163
+
164
+ describe('formatImHelp', () => {
165
+ it('lists the lightweight IM commands', () => {
166
+ const text = formatImHelp()
167
+ expect(text).toContain('/new')
168
+ expect(text).toContain('/projects')
169
+ expect(text).toContain('/status')
170
+ expect(text).toContain('/clear')
171
+ expect(text).toContain('/stop')
172
+ expect(text).toContain('/help')
173
+ expect(text).toContain('项目列表')
174
+ expect(text).toContain('/allow <id>')
175
+ })
176
+ })
177
+
178
+ describe('formatImStatus', () => {
179
+ it('formats an active session summary for mobile reading', () => {
180
+ const text = formatImStatus({
181
+ sessionId: 'abc1234567890',
182
+ projectName: 'claude-code-haha',
183
+ branch: 'main',
184
+ model: 'claude-sonnet',
185
+ state: 'tool_executing',
186
+ verb: 'Running tests',
187
+ pendingPermissionCount: 1,
188
+ taskCounts: {
189
+ total: 4,
190
+ pending: 1,
191
+ inProgress: 2,
192
+ completed: 1,
193
+ },
194
+ })
195
+
196
+ expect(text).toContain('项目: claude-code-haha (main)')
197
+ expect(text).toContain('会话: abc12345…')
198
+ expect(text).toContain('模型: claude-sonnet')
199
+ expect(text).toContain('状态: 执行工具中 (Running tests)')
200
+ expect(text).toContain('审批: 1 个待确认')
201
+ expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
202
+ })
203
+
204
+ it('returns a friendly empty-session message when nothing is active', () => {
205
+ const text = formatImStatus(null)
206
+ expect(text).toContain('当前没有活动会话')
207
+ expect(text).toContain('/new')
208
+ expect(text).toContain('/projects')
209
+ })
210
+ })
adapters/common/__tests__/http-client.test.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
2
+ import * as fs from 'node:fs'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import { AdapterHttpClient } from '../http-client.js'
6
+
7
+ describe('AdapterHttpClient', () => {
8
+ let client: AdapterHttpClient
9
+ const originalFetch = globalThis.fetch
10
+
11
+ beforeEach(() => {
12
+ client = new AdapterHttpClient('ws://127.0.0.1:3456')
13
+ })
14
+
15
+ afterEach(() => {
16
+ globalThis.fetch = originalFetch
17
+ })
18
+
19
+ it('derives HTTP URL from WS URL', () => {
20
+ expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
21
+
22
+ const secure = new AdapterHttpClient('wss://example.com:443')
23
+ expect(secure.httpBaseUrl).toBe('https://example.com:443')
24
+ })
25
+
26
+ it('createSession calls POST /api/sessions', async () => {
27
+ const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
28
+ globalThis.fetch = mock(() =>
29
+ Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
30
+ status: 201,
31
+ headers: { 'Content-Type': 'application/json' },
32
+ }))
33
+ ) as any
34
+
35
+ const sessionId = await client.createSession('/path/to/project')
36
+ expect(sessionId).toBe(mockSessionId)
37
+
38
+ const call = (globalThis.fetch as any).mock.calls[0]
39
+ expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
40
+ const body = JSON.parse(call[1].body)
41
+ expect(body.workDir).toBe('/path/to/project')
42
+ })
43
+
44
+ it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
45
+ const mockProjects = [
46
+ { projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
47
+ ]
48
+ globalThis.fetch = mock(() =>
49
+ Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
50
+ headers: { 'Content-Type': 'application/json' },
51
+ }))
52
+ ) as any
53
+
54
+ const projects = await client.listRecentProjects()
55
+ expect(projects).toHaveLength(1)
56
+ expect(projects[0].projectName).toBe('my-app')
57
+ })
58
+
59
+ it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
60
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
61
+ const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
62
+ try {
63
+ client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
64
+ globalThis.fetch = mock(() => {
65
+ throw new Error('recent projects should not be queried for absolute paths')
66
+ }) as any
67
+
68
+ const result = await client.matchProject(projectDir)
69
+
70
+ expect(result.project?.realPath).toBe(fs.realpathSync(projectDir))
71
+ expect(result.project?.projectName).toBe(path.basename(projectDir))
72
+ expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
73
+ } finally {
74
+ fs.rmSync(rootDir, { recursive: true, force: true })
75
+ }
76
+ })
77
+
78
+ it('matchProject rejects absolute local project paths outside allowed roots', async () => {
79
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
80
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-'))
81
+ try {
82
+ client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
83
+ globalThis.fetch = mock(() => {
84
+ throw new Error('recent projects should not be queried for rejected absolute paths')
85
+ }) as any
86
+
87
+ const result = await client.matchProject(projectDir)
88
+
89
+ expect(result.project).toBeUndefined()
90
+ expect(result.ambiguous).toBeUndefined()
91
+ expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
92
+ } finally {
93
+ fs.rmSync(rootDir, { recursive: true, force: true })
94
+ fs.rmSync(projectDir, { recursive: true, force: true })
95
+ }
96
+ })
97
+
98
+ it('createSession throws on server error', async () => {
99
+ globalThis.fetch = mock(() =>
100
+ Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
101
+ status: 400,
102
+ headers: { 'Content-Type': 'application/json' },
103
+ }))
104
+ ) as any
105
+
106
+ expect(client.createSession('')).rejects.toThrow()
107
+ })
108
+
109
+ it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
110
+ globalThis.fetch = mock(() =>
111
+ Promise.resolve(new Response(JSON.stringify({
112
+ branch: 'main',
113
+ repoName: 'claude-code-haha',
114
+ workDir: '/repo/claude-code-haha',
115
+ changedFiles: 2,
116
+ }), {
117
+ headers: { 'Content-Type': 'application/json' },
118
+ }))
119
+ ) as any
120
+
121
+ const gitInfo = await client.getGitInfo('session-123')
122
+ expect(gitInfo.repoName).toBe('claude-code-haha')
123
+ expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
124
+ 'http://127.0.0.1:3456/api/sessions/session-123/git-info',
125
+ )
126
+ })
127
+
128
+ it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
129
+ globalThis.fetch = mock(() =>
130
+ Promise.resolve(new Response(JSON.stringify({
131
+ tasks: [
132
+ { id: '1', subject: 'Fix bug', status: 'in_progress' },
133
+ { id: '2', subject: 'Write docs', status: 'pending' },
134
+ ],
135
+ }), {
136
+ headers: { 'Content-Type': 'application/json' },
137
+ }))
138
+ ) as any
139
+
140
+ const tasks = await client.getTasksForSession('session-123')
141
+ expect(tasks).toHaveLength(2)
142
+ expect(tasks[0]?.status).toBe('in_progress')
143
+ expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
144
+ 'http://127.0.0.1:3456/api/tasks/lists/session-123',
145
+ )
146
+ })
147
+ })
adapters/common/__tests__/message-buffer.test.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'bun:test'
2
+ import { MessageBuffer } from '../message-buffer.js'
3
+
4
+ describe('MessageBuffer', () => {
5
+ it('accumulates text and flushes on complete', async () => {
6
+ const flushed: Array<{ text: string; isComplete: boolean }> = []
7
+ const buf = new MessageBuffer(
8
+ (text, isComplete) => { flushed.push({ text, isComplete }) },
9
+ 500, // 500ms interval
10
+ 1000, // 1000 char threshold
11
+ )
12
+
13
+ buf.append('Hello ')
14
+ buf.append('World')
15
+ await buf.complete()
16
+
17
+ expect(flushed.length).toBeGreaterThanOrEqual(1)
18
+ const allText = flushed.map((f) => f.text).join('')
19
+ expect(allText).toBe('Hello World')
20
+ // Last flush should be marked complete
21
+ expect(flushed[flushed.length - 1]!.isComplete).toBe(true)
22
+ })
23
+
24
+ it('flushes when character threshold is reached', async () => {
25
+ const flushed: string[] = []
26
+ const buf = new MessageBuffer(
27
+ (text) => { flushed.push(text) },
28
+ 10000, // very long interval (won't trigger)
29
+ 10, // 10 char threshold
30
+ )
31
+
32
+ buf.append('12345678901') // 11 chars > threshold
33
+
34
+ // Wait for microtask
35
+ await new Promise((r) => setTimeout(r, 10))
36
+ expect(flushed.length).toBeGreaterThanOrEqual(1)
37
+
38
+ buf.reset()
39
+ })
40
+
41
+ it('flushes on timer interval', async () => {
42
+ const flushed: string[] = []
43
+ const buf = new MessageBuffer(
44
+ (text) => { flushed.push(text) },
45
+ 50, // 50ms interval
46
+ 1000,
47
+ )
48
+
49
+ buf.append('hi')
50
+
51
+ // Wait for timer
52
+ await new Promise((r) => setTimeout(r, 80))
53
+ expect(flushed).toContain('hi')
54
+
55
+ buf.reset()
56
+ })
57
+
58
+ it('does not flush empty buffer on complete', async () => {
59
+ const flushed: string[] = []
60
+ const buf = new MessageBuffer(
61
+ (text) => { flushed.push(text) },
62
+ )
63
+
64
+ await buf.complete()
65
+ expect(flushed.length).toBe(0)
66
+ })
67
+
68
+ it('waits for an in-flight flush before complete resolves', async () => {
69
+ let releaseFlush!: () => void
70
+ let flushStarted = false
71
+ const flushed: Array<{ text: string; isComplete: boolean }> = []
72
+ const buf = new MessageBuffer(
73
+ async (text, isComplete) => {
74
+ flushStarted = true
75
+ flushed.push({ text, isComplete })
76
+ await new Promise<void>((resolve) => {
77
+ releaseFlush = resolve
78
+ })
79
+ },
80
+ 10000,
81
+ 3,
82
+ )
83
+
84
+ buf.append('abcd')
85
+ await new Promise((resolve) => setTimeout(resolve, 0))
86
+ expect(flushStarted).toBe(true)
87
+
88
+ let completeResolved = false
89
+ const completing = buf.complete().then(() => {
90
+ completeResolved = true
91
+ })
92
+ await new Promise((resolve) => setTimeout(resolve, 0))
93
+ expect(completeResolved).toBe(false)
94
+
95
+ releaseFlush()
96
+ await completing
97
+ expect(completeResolved).toBe(true)
98
+ expect(flushed).toEqual([{ text: 'abcd', isComplete: false }])
99
+ })
100
+
101
+ it('resets properly between messages', async () => {
102
+ const flushed: string[] = []
103
+ const buf = new MessageBuffer(
104
+ (text) => { flushed.push(text) },
105
+ 500,
106
+ 1000,
107
+ )
108
+
109
+ buf.append('first')
110
+ buf.reset()
111
+ buf.append('second')
112
+ await buf.complete()
113
+
114
+ const allText = flushed.map((f) => f).join('')
115
+ expect(allText).toBe('second')
116
+ })
117
+ })
adapters/common/__tests__/message-dedup.test.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { MessageDedup } from '../message-dedup.js'
3
+
4
+ describe('MessageDedup', () => {
5
+ let dedup: MessageDedup
6
+
7
+ beforeEach(() => {
8
+ dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries
9
+ })
10
+
11
+ afterEach(() => {
12
+ dedup.destroy()
13
+ })
14
+
15
+ it('returns true for new messages', () => {
16
+ expect(dedup.tryRecord('msg-1')).toBe(true)
17
+ expect(dedup.tryRecord('msg-2')).toBe(true)
18
+ })
19
+
20
+ it('returns false for duplicate messages', () => {
21
+ expect(dedup.tryRecord('msg-1')).toBe(true)
22
+ expect(dedup.tryRecord('msg-1')).toBe(false)
23
+ expect(dedup.tryRecord('msg-1')).toBe(false)
24
+ })
25
+
26
+ it('allows same ID after TTL expires', async () => {
27
+ const shortDedup = new MessageDedup(50, 100) // 50ms TTL
28
+ expect(shortDedup.tryRecord('msg-1')).toBe(true)
29
+ expect(shortDedup.tryRecord('msg-1')).toBe(false)
30
+ await new Promise((r) => setTimeout(r, 60))
31
+ expect(shortDedup.tryRecord('msg-1')).toBe(true)
32
+ shortDedup.destroy()
33
+ })
34
+
35
+ it('evicts oldest entry when at capacity', () => {
36
+ const smallDedup = new MessageDedup(60_000, 3) // max 3 entries
37
+ expect(smallDedup.tryRecord('a')).toBe(true)
38
+ expect(smallDedup.tryRecord('b')).toBe(true)
39
+ expect(smallDedup.tryRecord('c')).toBe(true)
40
+ // Adding 4th should evict 'a'
41
+ expect(smallDedup.tryRecord('d')).toBe(true)
42
+ // 'a' was evicted, should be treated as new
43
+ expect(smallDedup.tryRecord('a')).toBe(true)
44
+ // Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted
45
+ // 'c' should still be deduped (was not evicted)
46
+ expect(smallDedup.tryRecord('c')).toBe(false)
47
+ smallDedup.destroy()
48
+ })
49
+
50
+ it('handles distinct messages independently', () => {
51
+ expect(dedup.tryRecord('msg-1')).toBe(true)
52
+ expect(dedup.tryRecord('msg-2')).toBe(true)
53
+ expect(dedup.tryRecord('msg-1')).toBe(false)
54
+ expect(dedup.tryRecord('msg-2')).toBe(false)
55
+ expect(dedup.tryRecord('msg-3')).toBe(true)
56
+ })
57
+ })
adapters/common/__tests__/pairing.test.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { isPaired } from '../pairing.js'
3
+
4
+ describe('pairing platform support', () => {
5
+ it('checks DingTalk paired users with the same shared access rule', () => {
6
+ expect(isPaired('dingtalk', 'staff-1', {
7
+ dingtalk: {
8
+ pairedUsers: [{ userId: 'staff-1', displayName: 'DingTalk User', pairedAt: Date.now() }],
9
+ allowedUsers: [],
10
+ },
11
+ })).toBe(true)
12
+ })
13
+
14
+ it('keeps empty DingTalk allow and pair lists closed by default', () => {
15
+ expect(isPaired('dingtalk', 'staff-1', {
16
+ dingtalk: {
17
+ pairedUsers: [],
18
+ allowedUsers: [],
19
+ },
20
+ })).toBe(false)
21
+ })
22
+ })
adapters/common/__tests__/permission.test.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ formatPermissionDecisionStatus,
4
+ formatPermissionInstructions,
5
+ parsePermissionCommand,
6
+ parsePermitCallbackData,
7
+ } from '../permission.js'
8
+
9
+ describe('permission helpers', () => {
10
+ it('parses text permission commands', () => {
11
+ expect(parsePermissionCommand('/allow req-1')).toEqual({ requestId: 'req-1', allowed: true })
12
+ expect(parsePermissionCommand('/always req-2')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' })
13
+ expect(parsePermissionCommand('/allow-always req-3')).toEqual({ requestId: 'req-3', allowed: true, rule: 'always' })
14
+ expect(parsePermissionCommand('/deny req-4')).toEqual({ requestId: 'req-4', allowed: false })
15
+ })
16
+
17
+ it('parses short replies when one permission is pending', () => {
18
+ const pending = new Set(['req-1'])
19
+ expect(parsePermissionCommand('1', pending)).toEqual({ requestId: 'req-1', allowed: true })
20
+ expect(parsePermissionCommand('2', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
21
+ expect(parsePermissionCommand('3', pending)).toEqual({ requestId: 'req-1', allowed: false })
22
+ expect(parsePermissionCommand('/always', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
23
+ expect(parsePermissionCommand('永久允许', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
24
+ })
25
+
26
+ it('does not parse short replies when multiple permissions are pending', () => {
27
+ expect(parsePermissionCommand('1', new Set(['req-1', 'req-2']))).toBeNull()
28
+ })
29
+
30
+ it('parses callback permission actions', () => {
31
+ expect(parsePermitCallbackData('permit:req-1:yes')).toEqual({ requestId: 'req-1', allowed: true })
32
+ expect(parsePermitCallbackData('permit:req-2:always')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' })
33
+ expect(parsePermitCallbackData('permit:req-3:no')).toEqual({ requestId: 'req-3', allowed: false })
34
+ expect(parsePermitCallbackData('permit:req-4:unknown')).toBeNull()
35
+ })
36
+
37
+ it('formats text fallback and status labels', () => {
38
+ expect(formatPermissionInstructions('req-1')).toContain('回复 1')
39
+ expect(formatPermissionInstructions('req-1')).toContain('/always req-1')
40
+ expect(formatPermissionDecisionStatus({ allowed: true, rule: 'always' })).toContain('永久允许')
41
+ expect(formatPermissionDecisionStatus({ allowed: false })).toContain('拒绝')
42
+ })
43
+ })
adapters/common/__tests__/session-store.test.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import * as fs from 'node:fs'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { SessionStore } from '../session-store.js'
6
+
7
+ describe('SessionStore', () => {
8
+ let tmpDir: string
9
+ let store: SessionStore
10
+
11
+ beforeEach(() => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
13
+ store = new SessionStore(path.join(tmpDir, 'sessions.json'))
14
+ })
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(tmpDir, { recursive: true, force: true })
18
+ })
19
+
20
+ it('returns null for unknown chatId', () => {
21
+ expect(store.get('unknown')).toBeNull()
22
+ })
23
+
24
+ it('stores and retrieves a session', () => {
25
+ store.set('chat-1', 'uuid-aaa', '/path/to/project')
26
+ const entry = store.get('chat-1')
27
+ expect(entry).not.toBeNull()
28
+ expect(entry!.sessionId).toBe('uuid-aaa')
29
+ expect(entry!.workDir).toBe('/path/to/project')
30
+ })
31
+
32
+ it('overwrites existing entry on set', () => {
33
+ store.set('chat-1', 'uuid-aaa', '/old')
34
+ store.set('chat-1', 'uuid-bbb', '/new')
35
+ expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
36
+ })
37
+
38
+ it('deletes an entry', () => {
39
+ store.set('chat-1', 'uuid-aaa', '/path')
40
+ store.delete('chat-1')
41
+ expect(store.get('chat-1')).toBeNull()
42
+ })
43
+
44
+ it('deletes every chat entry bound to a sessionId', () => {
45
+ store.set('chat-1', 'uuid-shared', '/project-a')
46
+ store.set('chat-2', 'uuid-other', '/project-b')
47
+ store.set('chat-3', 'uuid-shared', '/project-c')
48
+
49
+ const removed = store.deleteBySessionId('uuid-shared')
50
+
51
+ expect(removed.sort()).toEqual(['chat-1', 'chat-3'])
52
+ expect(store.get('chat-1')).toBeNull()
53
+ expect(store.get('chat-3')).toBeNull()
54
+ expect(store.get('chat-2')!.sessionId).toBe('uuid-other')
55
+
56
+ const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json'))
57
+ expect(reloaded.get('chat-1')).toBeNull()
58
+ expect(reloaded.get('chat-3')).toBeNull()
59
+ expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other')
60
+ })
61
+
62
+ it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => {
63
+ store.set('chat-1', 'uuid-stale', '/project')
64
+ const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json'))
65
+
66
+ expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1'])
67
+
68
+ expect(store.get('chat-1')).toBeNull()
69
+ expect(store.listAll()).toEqual([])
70
+ })
71
+
72
+ it('returns an empty list when deleting an unknown sessionId', () => {
73
+ store.set('chat-1', 'uuid-aaa', '/project')
74
+
75
+ expect(store.deleteBySessionId('uuid-missing')).toEqual([])
76
+ expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa')
77
+ })
78
+
79
+ it('persists to disk and reloads', () => {
80
+ store.set('chat-1', 'uuid-aaa', '/path')
81
+
82
+ const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
83
+ expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
84
+ })
85
+
86
+ it('handles missing file gracefully', () => {
87
+ const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
88
+ expect(store2.get('anything')).toBeNull()
89
+ })
90
+
91
+ it('lists all entries', () => {
92
+ store.set('chat-1', 'uuid-1', '/a')
93
+ store.set('chat-2', 'uuid-2', '/b')
94
+ const all = store.listAll()
95
+ expect(all).toHaveLength(2)
96
+ })
97
+ })
adapters/common/__tests__/ws-bridge.test.ts ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { WsBridge } from '../ws-bridge.js'
3
+ import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
4
+
5
+ describe('WsBridge', () => {
6
+ let bridge: WsBridge
7
+
8
+ beforeEach(() => {
9
+ bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
10
+ })
11
+
12
+ afterEach(() => {
13
+ bridge.destroy()
14
+ })
15
+
16
+ it('connectSession connects with provided sessionId', () => {
17
+ const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
18
+ expect(result).toBe(true)
19
+ expect(bridge.hasSession('chat-1')).toBe(true)
20
+ })
21
+
22
+ it('connectSession for different chatIds creates separate sessions', () => {
23
+ bridge.connectSession('chat-1', 'uuid-1')
24
+ bridge.connectSession('chat-2', 'uuid-2')
25
+ expect(bridge.hasSession('chat-1')).toBe(true)
26
+ expect(bridge.hasSession('chat-2')).toBe(true)
27
+ })
28
+
29
+ it('resetSession removes the session', () => {
30
+ bridge.connectSession('chat-reset', 'uuid-reset')
31
+ bridge.resetSession('chat-reset')
32
+ expect(bridge.hasSession('chat-reset')).toBe(false)
33
+ })
34
+
35
+ it('sendUserMessage returns false when no open connection', () => {
36
+ bridge.connectSession('chat-offline', 'uuid-offline')
37
+ expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
38
+ })
39
+
40
+ it('sendPermissionResponse returns false when no open connection', () => {
41
+ bridge.connectSession('chat-perm', 'uuid-perm')
42
+ expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
43
+ })
44
+
45
+ it('sendStopGeneration returns false when no open connection', () => {
46
+ bridge.connectSession('chat-stop', 'uuid-stop')
47
+ expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
48
+ })
49
+
50
+ it('destroy cleans up all sessions', () => {
51
+ bridge.connectSession('a', 'uuid-a')
52
+ bridge.connectSession('b', 'uuid-b')
53
+ bridge.destroy()
54
+ expect(bridge.hasSession('a')).toBe(false)
55
+ expect(bridge.hasSession('b')).toBe(false)
56
+ })
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Integration: per-chat handler serialization
61
+ //
62
+ // Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
63
+ // complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
64
+ // state msg 1's continuation is about to clear.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('WsBridge: handler serialization', () => {
68
+ let server: WebSocketServer
69
+ let port: number
70
+ let connections: WsServerSocket[]
71
+ let serverUrl: string
72
+
73
+ beforeEach(async () => {
74
+ connections = []
75
+ // port 0 → let the OS pick a free one
76
+ server = new WebSocketServer({ port: 0 })
77
+ server.on('connection', (ws) => {
78
+ connections.push(ws)
79
+ })
80
+ await new Promise<void>((resolve) => server.on('listening', () => resolve()))
81
+ port = (server.address() as { port: number }).port
82
+ serverUrl = `ws://127.0.0.1:${port}`
83
+ })
84
+
85
+ afterEach(async () => {
86
+ // Forcibly kill any server-side sockets (not graceful close) so
87
+ // WebSocketServer.close() doesn't wait for client FIN.
88
+ for (const ws of connections) {
89
+ try { ws.terminate() } catch {}
90
+ }
91
+ await new Promise<void>((resolve) => {
92
+ const t = setTimeout(() => resolve(), 500) // hard cap
93
+ server.close(() => {
94
+ clearTimeout(t)
95
+ resolve()
96
+ })
97
+ })
98
+ })
99
+
100
+ async function waitForServerConnection(): Promise<WsServerSocket> {
101
+ if (connections[0]) return connections[0]
102
+ await new Promise<void>((resolve, reject) => {
103
+ const onConnection = () => {
104
+ clearTimeout(timer)
105
+ resolve()
106
+ }
107
+ const timer = setTimeout(() => {
108
+ server.off('connection', onConnection)
109
+ reject(new Error('Timed out waiting for test WebSocket connection'))
110
+ }, 500)
111
+ server.once('connection', onConnection)
112
+ })
113
+ return connections[0]!
114
+ }
115
+
116
+ it('processes handler calls in strict FIFO order per chatId', async () => {
117
+ const bridge = new WsBridge(serverUrl, 'test')
118
+ const events: string[] = []
119
+
120
+ // The handler simulates an async side effect that takes varying time.
121
+ // If handlers ran concurrently, fast msgs could finish before slow ones,
122
+ // producing an out-of-order `events` array.
123
+ bridge.onServerMessage('chat-1', async (msg: any) => {
124
+ const tag = msg.tag as string
125
+ const delay = msg.delay as number
126
+ events.push(`start:${tag}`)
127
+ await new Promise((r) => setTimeout(r, delay))
128
+ events.push(`end:${tag}`)
129
+ })
130
+
131
+ bridge.connectSession('chat-1', 'sess-1')
132
+ const ok = await bridge.waitForOpen('chat-1')
133
+ expect(ok).toBe(true)
134
+ const serverWs = await waitForServerConnection()
135
+
136
+ // Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
137
+ // With serialization: start:1, end:1, start:2, end:2, start:3, end:3
138
+ // Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
139
+ serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
140
+ serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
141
+ serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
142
+
143
+ // Wait long enough for all three handlers to run serially
144
+ await new Promise((r) => setTimeout(r, 200))
145
+
146
+ expect(events).toEqual([
147
+ 'start:1', 'end:1',
148
+ 'start:2', 'end:2',
149
+ 'start:3', 'end:3',
150
+ ])
151
+
152
+ bridge.destroy()
153
+ })
154
+
155
+ it('handler error does not break the chain (subsequent messages still run)', async () => {
156
+ const bridge = new WsBridge(serverUrl, 'test')
157
+ const events: string[] = []
158
+
159
+ bridge.onServerMessage('chat-err', async (msg: any) => {
160
+ if (msg.throw) {
161
+ events.push('throwing')
162
+ throw new Error('boom')
163
+ }
164
+ events.push(`ok:${msg.tag}`)
165
+ })
166
+
167
+ bridge.connectSession('chat-err', 'sess-err')
168
+ await bridge.waitForOpen('chat-err')
169
+ const serverWs = await waitForServerConnection()
170
+
171
+ serverWs.send(JSON.stringify({ throw: true }))
172
+ serverWs.send(JSON.stringify({ tag: 'after' }))
173
+
174
+ await new Promise((r) => setTimeout(r, 80))
175
+
176
+ expect(events).toEqual(['throwing', 'ok:after'])
177
+
178
+ bridge.destroy()
179
+ })
180
+
181
+ it('forgets a chat when the server closes the session normally', async () => {
182
+ const bridge = new WsBridge(serverUrl, 'test')
183
+ bridge.onServerMessage('chat-deleted', () => {})
184
+ bridge.connectSession('chat-deleted', 'sess-deleted')
185
+ await bridge.waitForOpen('chat-deleted')
186
+
187
+ const serverWs = await waitForServerConnection()
188
+ serverWs.close(1000, 'session deleted')
189
+
190
+ await new Promise((resolve) => setTimeout(resolve, 50))
191
+
192
+ expect(bridge.hasSession('chat-deleted')).toBe(false)
193
+ await new Promise((resolve) => setTimeout(resolve, 1_100))
194
+ expect(connections).toHaveLength(1)
195
+
196
+ bridge.destroy()
197
+ })
198
+
199
+ it('resetSession clears the handler chain', async () => {
200
+ const bridge = new WsBridge(serverUrl, 'test')
201
+ bridge.onServerMessage('chat-reset', () => {})
202
+ bridge.connectSession('chat-reset', 'sess-reset')
203
+ await bridge.waitForOpen('chat-reset')
204
+
205
+ bridge.resetSession('chat-reset')
206
+ expect(bridge.hasSession('chat-reset')).toBe(false)
207
+
208
+ bridge.destroy()
209
+ })
210
+ })
adapters/common/attachment/__tests__/attachment-limits.test.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ checkAttachmentLimit,
4
+ IMAGE_MAX_BYTES,
5
+ FILE_MAX_BYTES,
6
+ IMAGE_MIME_WHITELIST,
7
+ } from '../attachment-limits.js'
8
+
9
+ describe('checkAttachmentLimit', () => {
10
+ it('accepts a 1 MB PNG image', () => {
11
+ const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
12
+ expect(result.ok).toBe(true)
13
+ })
14
+
15
+ it('rejects an 11 MB image as too_large', () => {
16
+ const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
17
+ expect(result.ok).toBe(false)
18
+ if (!result.ok) {
19
+ expect(result.reason).toBe('too_large')
20
+ expect(result.hint).toContain('10')
21
+ }
22
+ })
23
+
24
+ it('rejects an unsupported image mime', () => {
25
+ const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
26
+ expect(result.ok).toBe(false)
27
+ if (!result.ok) expect(result.reason).toBe('unsupported_mime')
28
+ })
29
+
30
+ it('rejects image/heic (not supported by Claude API)', () => {
31
+ const result = checkAttachmentLimit('image', 500_000, 'image/heic')
32
+ expect(result.ok).toBe(false)
33
+ if (!result.ok) expect(result.reason).toBe('unsupported_mime')
34
+ })
35
+
36
+ it('accepts a 10 MB PDF file', () => {
37
+ const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
38
+ expect(result.ok).toBe(true)
39
+ })
40
+
41
+ it('rejects a 31 MB file as too_large', () => {
42
+ const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
43
+ expect(result.ok).toBe(false)
44
+ if (!result.ok) expect(result.reason).toBe('too_large')
45
+ })
46
+
47
+ it('exposes the limits as exports', () => {
48
+ expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
49
+ expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
50
+ expect(IMAGE_MIME_WHITELIST).toContain('image/png')
51
+ })
52
+ })
adapters/common/attachment/__tests__/attachment-store.test.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as fsSync from 'node:fs'
4
+ import * as path from 'node:path'
5
+ import * as os from 'node:os'
6
+ import { AttachmentStore } from '../attachment-store.js'
7
+
8
+ let tmpRoot: string
9
+
10
+ beforeEach(async () => {
11
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await fs.rm(tmpRoot, { recursive: true, force: true })
16
+ })
17
+
18
+ describe('AttachmentStore', () => {
19
+ it('writes a buffer and returns the absolute path', async () => {
20
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
21
+ const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
22
+ const written = await store.write(target, Buffer.from('PNGDATA'))
23
+ expect(path.isAbsolute(written)).toBe(true)
24
+ const content = await fs.readFile(written)
25
+ expect(content.toString()).toBe('PNGDATA')
26
+ })
27
+
28
+ it('writes under {root}/{platform}/{sessionId}/', async () => {
29
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
30
+ const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
31
+ expect(target).toContain(path.join('telegram', 'sess-42'))
32
+ expect(target.endsWith('foo.pdf')).toBe(true)
33
+ })
34
+
35
+ it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
36
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
37
+ const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
38
+ // The resulting target must still live inside the store root.
39
+ const root = path.resolve(tmpRoot)
40
+ expect(path.resolve(target).startsWith(root)).toBe(true)
41
+ expect(path.basename(target)).not.toContain('..')
42
+ expect(path.basename(target)).not.toContain('/')
43
+ })
44
+
45
+ it('collapses name collisions by prefixing timestamps', async () => {
46
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
47
+ const a = store.resolvePath('feishu', 'sess-1', 'image.png')
48
+ await store.write(a, Buffer.from('first'))
49
+ const b = store.resolvePath('feishu', 'sess-1', 'image.png')
50
+ expect(b).not.toBe(a)
51
+ await store.write(b, Buffer.from('second'))
52
+ const contentB = await fs.readFile(b)
53
+ expect(contentB.toString()).toBe('second')
54
+ })
55
+
56
+ it('gc() removes files older than retentionMs and reports counts', async () => {
57
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
58
+ const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
59
+ await store.write(target, Buffer.from('STALE'))
60
+ // Age the file manually
61
+ const past = new Date(Date.now() - 10_000)
62
+ await fs.utimes(target, past, past)
63
+ const result = await store.gc()
64
+ expect(result.removed).toBe(1)
65
+ expect(result.bytes).toBe(5)
66
+ await expect(fs.access(target)).rejects.toThrow()
67
+ })
68
+
69
+ it('gc() keeps fresh files', async () => {
70
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
71
+ const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
72
+ await store.write(target, Buffer.from('FRESH'))
73
+ const result = await store.gc()
74
+ expect(result.removed).toBe(0)
75
+ await fs.access(target)
76
+ })
77
+
78
+ it('resolvePath under heavy collision pressure returns unique paths', () => {
79
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
80
+ // First create a file so subsequent resolves hit the collision branch
81
+ const a = store.resolvePath('feishu', 'sess-1', 'race.png')
82
+ fsSync.writeFileSync(a, 'first')
83
+ // Collect 50 resolved paths in a tight loop — none should clash
84
+ const seen = new Set<string>()
85
+ for (let i = 0; i < 50; i++) {
86
+ seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
87
+ }
88
+ expect(seen.size).toBe(50)
89
+ for (const p of seen) {
90
+ expect(p).not.toBe(a)
91
+ }
92
+ })
93
+
94
+ it('gc() cleans orphan .part files after a short grace period', async () => {
95
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
96
+ // Simulate a crashed write — leave a .part tmp file behind
97
+ const dir = path.join(tmpRoot, 'feishu', 'sess-1')
98
+ await fs.mkdir(dir, { recursive: true })
99
+ const orphan = path.join(dir, 'image.png.1234.5678.part')
100
+ await fs.writeFile(orphan, 'ORPHAN')
101
+ // Age the orphan so gc considers it stale
102
+ const past = new Date(Date.now() - 1000)
103
+ await fs.utimes(orphan, past, past)
104
+ const result = await store.gc()
105
+ expect(result.removed).toBeGreaterThanOrEqual(1)
106
+ await expect(fs.access(orphan)).rejects.toThrow()
107
+ })
108
+ })
adapters/common/attachment/__tests__/image-block-watcher.test.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { ImageBlockWatcher } from '../image-block-watcher.js'
3
+
4
+ describe('ImageBlockWatcher', () => {
5
+ it('extracts a markdown image with http URL', () => {
6
+ const w = new ImageBlockWatcher()
7
+ const out = w.feed('Here is ![alt](https://example.com/foo.png) an image.')
8
+ expect(out.length).toBe(1)
9
+ const source = out[0]!.source
10
+ expect(source.kind).toBe('url')
11
+ if (source.kind === 'url') {
12
+ expect(source.url).toBe('https://example.com/foo.png')
13
+ }
14
+ expect(out[0]!.alt).toBe('alt')
15
+ })
16
+
17
+ it('extracts a markdown image with absolute local path', () => {
18
+ const w = new ImageBlockWatcher()
19
+ const out = w.feed('![cat](/tmp/cat.jpg)')
20
+ expect(out.length).toBe(1)
21
+ const source = out[0]!.source
22
+ expect(source.kind).toBe('path')
23
+ if (source.kind === 'path') {
24
+ expect(source.path).toBe('/tmp/cat.jpg')
25
+ }
26
+ })
27
+
28
+ it('extracts a markdown image with file:// URL as path', () => {
29
+ const w = new ImageBlockWatcher()
30
+ const out = w.feed('![x](file:///var/img/x.png)')
31
+ const source = out[0]!.source
32
+ expect(source.kind).toBe('path')
33
+ if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png')
34
+ })
35
+
36
+ it('extracts a data URI as base64', () => {
37
+ const w = new ImageBlockWatcher()
38
+ const out = w.feed('![inline](data:image/png;base64,AAAA)')
39
+ const source = out[0]!.source
40
+ expect(source.kind).toBe('base64')
41
+ if (source.kind === 'base64') {
42
+ expect(source.mime).toBe('image/png')
43
+ expect(source.data).toBe('AAAA')
44
+ }
45
+ })
46
+
47
+ it('deduplicates the same image across multiple feeds', () => {
48
+ const w = new ImageBlockWatcher()
49
+ const a = w.feed('![](https://x/y.png)')
50
+ const b = w.feed(' repeated ![](https://x/y.png) again')
51
+ expect(a.length).toBe(1)
52
+ expect(b.length).toBe(0)
53
+ })
54
+
55
+ it('handles images split across feed boundaries', () => {
56
+ const w = new ImageBlockWatcher()
57
+ const a = w.feed('a ![al')
58
+ const b = w.feed('t](/tmp/x.png) b')
59
+ expect(a.length).toBe(0)
60
+ expect(b.length).toBe(1)
61
+ const source = b[0]!.source
62
+ expect(source.kind).toBe('path')
63
+ if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png')
64
+ })
65
+
66
+ it('skips non-image markdown links', () => {
67
+ const w = new ImageBlockWatcher()
68
+ const out = w.feed('See [docs](https://example.com).')
69
+ expect(out.length).toBe(0)
70
+ })
71
+
72
+ it('drain() returns all accumulated uploads', () => {
73
+ const w = new ImageBlockWatcher()
74
+ w.feed('![a](/tmp/a.png)')
75
+ w.feed(' and ![b](/tmp/b.png)')
76
+ const all = w.drain()
77
+ expect(all.length).toBe(2)
78
+ })
79
+
80
+ it('reset() clears buffer, seen set, and accumulated list', () => {
81
+ const w = new ImageBlockWatcher()
82
+ w.feed('![a](/tmp/a.png)')
83
+ w.reset()
84
+ // After reset, drain() is empty
85
+ expect(w.drain().length).toBe(0)
86
+ // And re-feeding the same image yields a fresh emit (dedup state cleared)
87
+ const out = w.feed('![a](/tmp/a.png)')
88
+ expect(out.length).toBe(1)
89
+ })
90
+
91
+ it('skips relative paths (cannot be resolved safely)', () => {
92
+ const w = new ImageBlockWatcher()
93
+ const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)')
94
+ expect(out.length).toBe(1)
95
+ const source = out[0]!.source
96
+ expect(source.kind).toBe('path')
97
+ if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
98
+ })
99
+
100
+ it('extracts multiple images from a single feed chunk in order', () => {
101
+ const w = new ImageBlockWatcher()
102
+ const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)')
103
+ expect(out.length).toBe(3)
104
+ expect(out[0]!.source.kind).toBe('path')
105
+ expect(out[1]!.source.kind).toBe('url')
106
+ expect(out[2]!.source.kind).toBe('base64')
107
+ })
108
+
109
+ it('rejects malformed data URI (not base64)', () => {
110
+ const w = new ImageBlockWatcher()
111
+ const out = w.feed('![bad](data:image/png,ABC)')
112
+ // Not in `;base64,` form → classify returns null → skipped
113
+ expect(out.length).toBe(0)
114
+ })
115
+ })
adapters/common/attachment/attachment-limits.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Size and MIME restrictions for IM attachments.
3
+ *
4
+ * Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file)
5
+ * and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's
6
+ * own image size bounds.
7
+ */
8
+
9
+ export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
10
+ export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB
11
+
12
+ export const IMAGE_MIME_WHITELIST = [
13
+ 'image/jpeg',
14
+ 'image/png',
15
+ 'image/gif',
16
+ 'image/webp',
17
+ ] as const
18
+
19
+ export type LimitCheckResult =
20
+ | { ok: true }
21
+ | { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string }
22
+
23
+ function formatMb(bytes: number): string {
24
+ return (bytes / (1024 * 1024)).toFixed(1)
25
+ }
26
+
27
+ export function checkAttachmentLimit(
28
+ kind: 'image' | 'file',
29
+ size: number,
30
+ mime?: string,
31
+ ): LimitCheckResult {
32
+ if (kind === 'image') {
33
+ if (size > IMAGE_MAX_BYTES) {
34
+ return {
35
+ ok: false,
36
+ reason: 'too_large',
37
+ hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`,
38
+ }
39
+ }
40
+ if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) {
41
+ return {
42
+ ok: false,
43
+ reason: 'unsupported_mime',
44
+ hint: `📎 暂不支持此图片格式(${mime})`,
45
+ }
46
+ }
47
+ return { ok: true }
48
+ }
49
+ // kind === 'file'
50
+ if (size > FILE_MAX_BYTES) {
51
+ return {
52
+ ok: false,
53
+ reason: 'too_large',
54
+ hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`,
55
+ }
56
+ }
57
+ return { ok: true }
58
+ }
adapters/common/attachment/attachment-store.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Local staging directory for IM-downloaded resources.
3
+ *
4
+ * Layout: {root}/{platform}/{sessionId}/{safeName}
5
+ * Default root: ~/.claude/im-downloads
6
+ *
7
+ * Responsibilities:
8
+ * - Generate unique, safe paths from (platform, sessionId, originalName)
9
+ * - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
10
+ * - GC files that haven't been touched for `retentionMs` (default 24h)
11
+ */
12
+
13
+ import * as fs from 'node:fs/promises'
14
+ import * as fsSync from 'node:fs'
15
+ import type { Dirent } from 'node:fs'
16
+ import * as path from 'node:path'
17
+ import * as os from 'node:os'
18
+ import type { ImPlatform } from './attachment-types.js'
19
+
20
+ export interface AttachmentStoreConfig {
21
+ root: string
22
+ retentionMs: number
23
+ /** Grace window before a `.part` orphan (left behind by a crashed writer)
24
+ * is eligible for GC. Default 10 minutes. */
25
+ orphanGraceMs: number
26
+ }
27
+
28
+ const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
29
+ const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
30
+
31
+ function defaultRoot(): string {
32
+ return path.join(os.homedir(), '.claude', 'im-downloads')
33
+ }
34
+
35
+ /** Strip path separators / .. / control chars from a filename. */
36
+ function sanitizeFilename(name: string): string {
37
+ // eslint-disable-next-line no-control-regex
38
+ const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
39
+ const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
40
+ return cleaned.trim() || 'unnamed'
41
+ }
42
+
43
+ export class AttachmentStore {
44
+ private readonly root: string
45
+ private readonly retentionMs: number
46
+ private readonly orphanGraceMs: number
47
+
48
+ constructor(config?: Partial<AttachmentStoreConfig>) {
49
+ this.root = config?.root ?? defaultRoot()
50
+ this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
51
+ this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
52
+ }
53
+
54
+ /** Compute the target path. Creates parent dirs on demand.
55
+ * If a file with the same name already exists, prefix with a timestamp
56
+ * to avoid clobbering. */
57
+ resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
58
+ const safeSession = sanitizeFilename(sessionId)
59
+ const dir = path.join(this.root, platform, safeSession)
60
+ fsSync.mkdirSync(dir, { recursive: true })
61
+ const safeName = sanitizeFilename(name)
62
+ const candidate = path.join(dir, safeName)
63
+ if (!fsSync.existsSync(candidate)) return candidate
64
+ const { name: base, ext } = path.parse(safeName)
65
+ // Collisions are rare in practice, but multiple downloads landing in the
66
+ // same millisecond must still produce unique paths — append a random
67
+ // suffix so the bare timestamp alone never clashes.
68
+ const rand = Math.random().toString(36).slice(2, 8)
69
+ return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
70
+ }
71
+
72
+ /** Write atomically: stream to {target}.part, then rename. */
73
+ async write(target: string, data: Buffer): Promise<string> {
74
+ await fs.mkdir(path.dirname(target), { recursive: true })
75
+ const tmp = `${target}.${process.pid}.${Date.now()}.part`
76
+ await fs.writeFile(tmp, data)
77
+ await fs.rename(tmp, target)
78
+ return target
79
+ }
80
+
81
+ /** Remove files older than retentionMs. Returns summary. */
82
+ async gc(): Promise<{ removed: number; bytes: number }> {
83
+ let removed = 0
84
+ let bytes = 0
85
+ const now = Date.now()
86
+
87
+ const walk = async (dir: string): Promise<void> => {
88
+ let entries: Dirent<string>[]
89
+ try {
90
+ // Pass encoding explicitly so Dirent stays string-typed under
91
+ // newer @types/node where the Buffer overload becomes the default.
92
+ entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
93
+ } catch {
94
+ return
95
+ }
96
+ for (const entry of entries) {
97
+ const full = path.join(dir, entry.name)
98
+ if (entry.isDirectory()) {
99
+ await walk(full)
100
+ } else if (entry.isFile()) {
101
+ try {
102
+ const stat = await fs.stat(full)
103
+ const age = now - stat.mtimeMs
104
+ const isOrphanPart = entry.name.endsWith('.part')
105
+ const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
106
+ if (age > threshold) {
107
+ bytes += stat.size
108
+ await fs.unlink(full)
109
+ removed++
110
+ }
111
+ } catch {
112
+ // ignore races
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ await walk(this.root).catch(() => {})
119
+ return { removed, bytes }
120
+ }
121
+ }
adapters/common/attachment/attachment-types.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared attachment types for IM adapters.
3
+ */
4
+
5
+ import type { AttachmentRef } from '../ws-bridge.js'
6
+ export type { AttachmentRef }
7
+
8
+ /** Platform tag — used for local staging subdir and telemetry. */
9
+ export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk'
10
+
11
+ /** Result of downloading an IM resource into the local stage dir. */
12
+ export interface LocalAttachment {
13
+ kind: 'image' | 'file'
14
+ name: string // original filename, or synthesized if none
15
+ path: string // absolute path on disk (under ~/.claude/im-downloads)
16
+ size: number // bytes
17
+ mimeType: string // detected or provided
18
+ buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
19
+ }
20
+
21
+ /** Pending outbound media found in Agent stream output. */
22
+ export interface PendingUpload {
23
+ id: string // fingerprint, used for dedup
24
+ source:
25
+ | { kind: 'base64'; data: string; mime: string }
26
+ | { kind: 'path'; path: string; mime?: string }
27
+ | { kind: 'url'; url: string; mime?: string }
28
+ alt?: string
29
+ }
adapters/common/attachment/image-block-watcher.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure, stateful extractor that watches a stream of assistant text for
3
+ * markdown image references (`![alt](source)`) and emits PendingUpload
4
+ * records. Used by IM adapters to know which images to upload to IM.
5
+ *
6
+ * - Buffers input so an image marker split across multiple feed() calls
7
+ * still gets detected.
8
+ * - Dedups by fingerprint of the source so the same image is only emitted
9
+ * once per watcher lifetime.
10
+ */
11
+
12
+ import type { PendingUpload } from './attachment-types.js'
13
+
14
+ // Matches a complete markdown image: ![alt](target)
15
+ // `alt` may be empty; `target` stops at the first closing paren.
16
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
17
+
18
+ function fingerprint(raw: string): string {
19
+ let h = 5381
20
+ for (let i = 0; i < raw.length; i++) {
21
+ h = ((h << 5) + h) ^ raw.charCodeAt(i)
22
+ }
23
+ return (h >>> 0).toString(16)
24
+ }
25
+
26
+ function classify(target: string): PendingUpload['source'] | null {
27
+ if (target.startsWith('data:')) {
28
+ const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
29
+ if (!m) return null
30
+ return { kind: 'base64', mime: m[1]!, data: m[2]! }
31
+ }
32
+ if (target.startsWith('file://')) {
33
+ return { kind: 'path', path: target.slice('file://'.length) }
34
+ }
35
+ if (target.startsWith('http://') || target.startsWith('https://')) {
36
+ return { kind: 'url', url: target }
37
+ }
38
+ if (target.startsWith('/')) {
39
+ return { kind: 'path', path: target }
40
+ }
41
+ return null // relative paths — skip, we can't resolve them safely
42
+ }
43
+
44
+ export class ImageBlockWatcher {
45
+ private buffer = ''
46
+ private seen = new Set<string>()
47
+ private accumulated: PendingUpload[] = []
48
+
49
+ /** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
50
+ feed(chunk: string): PendingUpload[] {
51
+ this.buffer += chunk
52
+ const out: PendingUpload[] = []
53
+
54
+ IMAGE_RE.lastIndex = 0
55
+ let lastConsumedEnd = 0
56
+ let m: RegExpExecArray | null
57
+ while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
58
+ const [, alt, target] = m
59
+ const source = classify(target!)
60
+ if (source) {
61
+ const id = fingerprint(`${source.kind}:${target}`)
62
+ if (!this.seen.has(id)) {
63
+ this.seen.add(id)
64
+ const pending: PendingUpload = { id, source, alt: alt || undefined }
65
+ out.push(pending)
66
+ this.accumulated.push(pending)
67
+ }
68
+ }
69
+ lastConsumedEnd = m.index + m[0].length
70
+ }
71
+
72
+ // Preserve tail that might contain a partially-received marker.
73
+ if (lastConsumedEnd > 0) {
74
+ this.buffer = this.buffer.slice(lastConsumedEnd)
75
+ }
76
+ if (this.buffer.length > 4096) {
77
+ this.buffer = this.buffer.slice(-2048)
78
+ }
79
+
80
+ return out
81
+ }
82
+
83
+ /** Return everything seen so far (for end-of-stream reconciliation). */
84
+ drain(): PendingUpload[] {
85
+ return [...this.accumulated]
86
+ }
87
+
88
+ /** Reset watcher state (use at /clear or new session). */
89
+ reset(): void {
90
+ this.buffer = ''
91
+ this.seen.clear()
92
+ this.accumulated = []
93
+ }
94
+ }
adapters/common/chat-queue.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 会话串行队列
3
+ *
4
+ * 同一 chatId 的消息串行处理,防并发冲突。
5
+ * 不同 chatId 之间互不影响。
6
+ * 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
7
+ */
8
+
9
+ const queues = new Map<string, Promise<void>>()
10
+
11
+ export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
12
+ const prev = queues.get(chatId) ?? Promise.resolve()
13
+ const next = prev.then(fn, () => fn()).catch((err) => {
14
+ console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
15
+ })
16
+ queues.set(chatId, next)
17
+ // Clean up after completion to avoid memory leak for one-off chats
18
+ next.finally(() => {
19
+ if (queues.get(chatId) === next) {
20
+ queues.delete(chatId)
21
+ }
22
+ })
23
+ return next
24
+ }
adapters/common/config.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Adapter 配置加载
3
+ *
4
+ * 优先级:环境变量 > ~/.claude/adapters.json > 默认值
5
+ */
6
+
7
+ import * as fs from 'node:fs'
8
+ import * as os from 'node:os'
9
+ import * as path from 'node:path'
10
+
11
+ export type PairedUser = {
12
+ userId: string | number
13
+ displayName: string
14
+ pairedAt: number
15
+ }
16
+
17
+ export type PairingState = {
18
+ code: string | null
19
+ expiresAt: number | null
20
+ createdAt: number | null
21
+ }
22
+
23
+ export type TelegramConfig = {
24
+ botToken: string
25
+ allowedUsers: number[]
26
+ pairedUsers: PairedUser[]
27
+ defaultWorkDir: string
28
+ }
29
+
30
+ export type FeishuConfig = {
31
+ appId: string
32
+ appSecret: string
33
+ encryptKey: string
34
+ verificationToken: string
35
+ allowedUsers: string[]
36
+ pairedUsers: PairedUser[]
37
+ defaultWorkDir: string
38
+ streamingCard: boolean
39
+ }
40
+
41
+ export type WechatConfig = {
42
+ accountId: string
43
+ botToken: string
44
+ baseUrl: string
45
+ userId: string
46
+ allowedUsers: string[]
47
+ pairedUsers: PairedUser[]
48
+ defaultWorkDir: string
49
+ }
50
+
51
+ export type DingtalkConfig = {
52
+ clientId: string
53
+ clientSecret: string
54
+ allowedUsers: string[]
55
+ pairedUsers: PairedUser[]
56
+ defaultWorkDir: string
57
+ endpoint: string
58
+ permissionCardTemplateId: string
59
+ }
60
+
61
+ export type AdapterConfig = {
62
+ serverUrl: string
63
+ defaultProjectDir: string
64
+ pairing: PairingState
65
+ telegram: TelegramConfig
66
+ feishu: FeishuConfig
67
+ wechat: WechatConfig
68
+ dingtalk: DingtalkConfig
69
+ }
70
+
71
+ export type AdapterPlatformConfig =
72
+ | TelegramConfig
73
+ | FeishuConfig
74
+ | WechatConfig
75
+ | DingtalkConfig
76
+
77
+ function getConfigPath(): string {
78
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
79
+ return path.join(configDir, 'adapters.json')
80
+ }
81
+
82
+ function loadFile(): Record<string, any> {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
85
+ } catch (err: any) {
86
+ if (err?.code !== 'ENOENT') {
87
+ console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
88
+ }
89
+ return {}
90
+ }
91
+ }
92
+
93
+ export function loadConfig(): AdapterConfig {
94
+ const file = loadFile()
95
+ const tg = file.telegram ?? {}
96
+ const fs_ = file.feishu ?? {}
97
+ const wc = file.wechat ?? {}
98
+ const dt = file.dingtalk ?? {}
99
+ const pairing = file.pairing ?? {}
100
+ const fallbackWorkDir = resolveUserDefaultWorkDir()
101
+
102
+ return {
103
+ serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
104
+ defaultProjectDir: file.defaultProjectDir || '',
105
+ pairing: {
106
+ code: pairing.code ?? null,
107
+ expiresAt: pairing.expiresAt ?? null,
108
+ createdAt: pairing.createdAt ?? null,
109
+ },
110
+ telegram: {
111
+ botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
112
+ allowedUsers: tg.allowedUsers ?? [],
113
+ pairedUsers: tg.pairedUsers ?? [],
114
+ defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir,
115
+ },
116
+ feishu: {
117
+ appId: process.env.FEISHU_APP_ID || fs_.appId || '',
118
+ appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
119
+ encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
120
+ verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
121
+ allowedUsers: fs_.allowedUsers ?? [],
122
+ pairedUsers: fs_.pairedUsers ?? [],
123
+ defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir,
124
+ streamingCard: fs_.streamingCard ?? false,
125
+ },
126
+ wechat: {
127
+ accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '',
128
+ botToken: process.env.WECHAT_BOT_TOKEN || wc.botToken || '',
129
+ baseUrl: process.env.WECHAT_BASE_URL || wc.baseUrl || 'https://ilinkai.weixin.qq.com',
130
+ userId: process.env.WECHAT_USER_ID || wc.userId || '',
131
+ allowedUsers: wc.allowedUsers ?? [],
132
+ pairedUsers: wc.pairedUsers ?? [],
133
+ defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir,
134
+ },
135
+ dingtalk: {
136
+ clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '',
137
+ clientSecret: process.env.DINGTALK_CLIENT_SECRET || dt.clientSecret || '',
138
+ allowedUsers: dt.allowedUsers ?? [],
139
+ pairedUsers: dt.pairedUsers ?? [],
140
+ defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir,
141
+ endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com',
142
+ permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '',
143
+ },
144
+ }
145
+ }
146
+
147
+ export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: AdapterPlatformConfig): string {
148
+ return config.defaultProjectDir || platformConfig.defaultWorkDir
149
+ }
150
+
151
+ function resolveUserDefaultWorkDir(): string {
152
+ const candidates = [
153
+ process.env.ADAPTER_DEFAULT_PROJECT_DIR,
154
+ process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
155
+ process.env.PWD,
156
+ process.cwd(),
157
+ os.homedir(),
158
+ ]
159
+
160
+ for (const candidate of candidates) {
161
+ const resolved = resolveExistingDirectory(candidate)
162
+ if (resolved) return resolved
163
+ }
164
+
165
+ return os.homedir()
166
+ }
167
+
168
+ function resolveExistingDirectory(value: string | undefined): string | null {
169
+ const trimmed = value?.trim()
170
+ if (!trimmed) return null
171
+
172
+ const expanded = trimmed === '~'
173
+ ? os.homedir()
174
+ : trimmed.startsWith('~/')
175
+ ? path.join(os.homedir(), trimmed.slice(2))
176
+ : trimmed
177
+
178
+ try {
179
+ const realPath = fs.realpathSync(expanded)
180
+ return fs.statSync(realPath).isDirectory() ? realPath : null
181
+ } catch {
182
+ return null
183
+ }
184
+ }
adapters/common/format.ts ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 消息格式化工具
3
+ */
4
+
5
+ type AdapterChatState =
6
+ | 'idle'
7
+ | 'thinking'
8
+ | 'streaming'
9
+ | 'tool_executing'
10
+ | 'permission_pending'
11
+
12
+ type ImStatusSummary = {
13
+ sessionId?: string
14
+ projectName?: string | null
15
+ branch?: string | null
16
+ model?: string | null
17
+ state?: AdapterChatState | null
18
+ verb?: string | null
19
+ pendingPermissionCount?: number
20
+ taskCounts?: {
21
+ total: number
22
+ pending: number
23
+ inProgress: number
24
+ completed: number
25
+ }
26
+ }
27
+
28
+ const IM_HELP_LINES = [
29
+ '/new [项目] / 新会话 — 新建会话或切换项目',
30
+ '/projects / 项目列表 — 查看最近项目',
31
+ '/status / 状态 — 查看当前会话状态',
32
+ '/clear / 清空 — 清空当前会话上下文',
33
+ '/stop / 停止 — 停止当前生成',
34
+ '/help / 帮助 — 显示这份帮助',
35
+ '权限审批:/allow <id>、/always <id>、/deny <id>',
36
+ ]
37
+
38
+ /** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
39
+ export function splitMessage(text: string, limit: number): string[] {
40
+ if (text.length <= limit) return [text]
41
+
42
+ const chunks: string[] = []
43
+ let remaining = text
44
+
45
+ while (remaining.length > 0) {
46
+ if (remaining.length <= limit) {
47
+ chunks.push(remaining)
48
+ break
49
+ }
50
+
51
+ let splitAt = remaining.lastIndexOf('\n\n', limit)
52
+ if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
53
+ if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
54
+ if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
55
+ if (splitAt <= 0) splitAt = limit
56
+
57
+ // Include the delimiter for paragraph/sentence breaks
58
+ if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
59
+
60
+ chunks.push(remaining.slice(0, splitAt).trimEnd())
61
+ remaining = remaining.slice(splitAt).trimStart()
62
+ }
63
+
64
+ return chunks
65
+ }
66
+
67
+ type MarkdownTable = {
68
+ headers: string[]
69
+ rows: string[][]
70
+ }
71
+
72
+ function splitMarkdownTableRow(line: string): string[] {
73
+ const trimmed = line.trim()
74
+ const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
75
+ const withoutTrailingPipe = inner.endsWith('|') ? inner.slice(0, -1) : inner
76
+ return withoutTrailingPipe.split('|').map((cell) => cell.trim())
77
+ }
78
+
79
+ function isMarkdownTableDivider(line: string): boolean {
80
+ const cells = splitMarkdownTableRow(line)
81
+ if (cells.length < 2) return false
82
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
83
+ }
84
+
85
+ function isPotentialMarkdownTableRow(line: string): boolean {
86
+ const trimmed = line.trim()
87
+ return trimmed.includes('|') && splitMarkdownTableRow(trimmed).length >= 2
88
+ }
89
+
90
+ function isFenceMarker(line: string): boolean {
91
+ return /^\s*(```|~~~)/.test(line)
92
+ }
93
+
94
+ function formatMarkdownTableAsBullets(table: MarkdownTable): string {
95
+ const { headers, rows } = table
96
+ if (headers.length === 0 || rows.length === 0) return ''
97
+
98
+ const output: string[] = []
99
+
100
+ for (const row of rows) {
101
+ if (row.every((cell) => !cell)) continue
102
+
103
+ const label = row[0]
104
+ if (label) output.push(label)
105
+
106
+ for (let i = 1; i < Math.max(headers.length, row.length); i++) {
107
+ const value = row[i]
108
+ if (!value) continue
109
+ const header = headers[i]
110
+ output.push(`• ${header ? `${header}: ` : `Column ${i}: `}${value}`)
111
+ }
112
+
113
+ if (output[output.length - 1] !== '') output.push('')
114
+ }
115
+
116
+ while (output[output.length - 1] === '') output.pop()
117
+ return output.join('\n')
118
+ }
119
+
120
+ /** Convert GitHub-flavored Markdown pipe tables into mobile-friendly bullet lists. */
121
+ export function convertMarkdownTablesToBullets(markdown: string): string {
122
+ const lines = markdown.split('\n')
123
+ const output: string[] = []
124
+ let inFence = false
125
+ let i = 0
126
+
127
+ while (i < lines.length) {
128
+ const headerLine = lines[i] ?? ''
129
+
130
+ if (isFenceMarker(headerLine)) {
131
+ inFence = !inFence
132
+ output.push(headerLine)
133
+ i += 1
134
+ continue
135
+ }
136
+
137
+ const dividerLine = lines[i + 1] ?? ''
138
+ if (!inFence && isPotentialMarkdownTableRow(headerLine) && isMarkdownTableDivider(dividerLine)) {
139
+ const headers = splitMarkdownTableRow(headerLine)
140
+ const rows: string[][] = []
141
+ i += 2
142
+
143
+ while (i < lines.length && isPotentialMarkdownTableRow(lines[i] ?? '')) {
144
+ rows.push(splitMarkdownTableRow(lines[i] ?? ''))
145
+ i += 1
146
+ }
147
+
148
+ const rendered = formatMarkdownTableAsBullets({ headers, rows })
149
+ if (rendered) output.push(rendered)
150
+ continue
151
+ }
152
+
153
+ output.push(headerLine)
154
+ i += 1
155
+ }
156
+
157
+ return output.join('\n')
158
+ }
159
+
160
+ /** Format tool use info for display in IM. */
161
+ export function formatToolUse(toolName: string, input: unknown): string {
162
+ const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
163
+ const summary = formatToolSummary(toolName, inp)
164
+ if (summary) return `🔧 ${toolName} ${summary}`
165
+ const preview = truncateInput(input, 200)
166
+ return `🔧 ${toolName}\n${preview}`
167
+ }
168
+
169
+ /** Generate a concise human-readable summary for common tools. */
170
+ function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
171
+ switch (tool) {
172
+ case 'Bash': {
173
+ const desc = inp.description as string | undefined
174
+ const cmd = inp.command as string | undefined
175
+ if (desc) return desc
176
+ if (cmd) return truncate(cmd, 120)
177
+ return null
178
+ }
179
+ case 'Read': {
180
+ const fp = inp.file_path as string | undefined
181
+ if (fp) return shortPath(fp)
182
+ return null
183
+ }
184
+ case 'Edit': {
185
+ const fp = inp.file_path as string | undefined
186
+ if (fp) return shortPath(fp)
187
+ return null
188
+ }
189
+ case 'Write': {
190
+ const fp = inp.file_path as string | undefined
191
+ if (fp) return shortPath(fp)
192
+ return null
193
+ }
194
+ case 'Grep': {
195
+ const pat = inp.pattern as string | undefined
196
+ const p = inp.path as string | undefined
197
+ if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
198
+ return null
199
+ }
200
+ case 'Glob': {
201
+ const pat = inp.pattern as string | undefined
202
+ return pat ? `"${pat}"` : null
203
+ }
204
+ case 'Skill': {
205
+ const skill = inp.skill as string | undefined
206
+ return skill || null
207
+ }
208
+ case 'Agent': {
209
+ const desc = inp.description as string | undefined
210
+ return desc || null
211
+ }
212
+ case 'WebFetch': {
213
+ const url = inp.url as string | undefined
214
+ return url ? truncate(url, 120) : null
215
+ }
216
+ case 'WebSearch': {
217
+ const q = inp.query as string | undefined
218
+ return q ? `"${truncate(q, 80)}"` : null
219
+ }
220
+ default:
221
+ return null
222
+ }
223
+ }
224
+
225
+ function shortPath(fp: string): string {
226
+ const parts = fp.split('/')
227
+ return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
228
+ }
229
+
230
+ function truncate(s: string, max: number): string {
231
+ return s.length > max ? s.slice(0, max) + '…' : s
232
+ }
233
+
234
+ /** Format a permission request for display in IM. */
235
+ export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
236
+ const preview = truncateInput(input, 300)
237
+ return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
238
+ }
239
+
240
+ /** Truncate tool input to a preview string. */
241
+ export function truncateInput(input: unknown, maxLen: number): string {
242
+ try {
243
+ const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
244
+ return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
245
+ } catch {
246
+ return '(unserializable)'
247
+ }
248
+ }
249
+
250
+ /** Escape special characters for Telegram MarkdownV2. */
251
+ export function escapeMarkdownV2(text: string): string {
252
+ return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
253
+ }
254
+
255
+ export function formatImHelp(): string {
256
+ return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
257
+ }
258
+
259
+ export function formatImStatus(summary: ImStatusSummary | null): string {
260
+ if (!summary?.sessionId) {
261
+ return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
262
+ }
263
+
264
+ const lines = ['当前会话状态:']
265
+
266
+ if (summary.projectName) {
267
+ lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
268
+ } else if (summary.branch) {
269
+ lines.push(`分支: ${summary.branch}`)
270
+ }
271
+
272
+ lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
273
+
274
+ if (summary.model) {
275
+ lines.push(`模型: ${summary.model}`)
276
+ }
277
+
278
+ lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
279
+
280
+ const pendingPermissionCount = summary.pendingPermissionCount ?? 0
281
+ if (pendingPermissionCount > 0) {
282
+ lines.push(`审批: ${pendingPermissionCount} 个待确认`)
283
+ }
284
+
285
+ const taskCounts = summary.taskCounts
286
+ if (taskCounts && taskCounts.total > 0) {
287
+ const taskParts = [`总计 ${taskCounts.total}`]
288
+ if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
289
+ if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
290
+ if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
291
+ lines.push(`任务: ${taskParts.join(' · ')}`)
292
+ }
293
+
294
+ return lines.join('\n')
295
+ }
296
+
297
+ function formatAdapterChatState(
298
+ state: AdapterChatState | null | undefined,
299
+ verb: string | null | undefined,
300
+ ): string {
301
+ const label = (() => {
302
+ switch (state) {
303
+ case 'thinking':
304
+ return '思考中'
305
+ case 'streaming':
306
+ return '生成中'
307
+ case 'tool_executing':
308
+ return '执行工具中'
309
+ case 'permission_pending':
310
+ return '等待权限确认'
311
+ case 'idle':
312
+ default:
313
+ return '空闲'
314
+ }
315
+ })()
316
+
317
+ if (!verb || verb === 'Thinking') return label
318
+ return `${label} (${verb})`
319
+ }
320
+
321
+ function shortSessionId(sessionId: string): string {
322
+ return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
323
+ }
adapters/common/http-client.ts ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ export type RecentProject = {
6
+ projectPath: string
7
+ realPath: string
8
+ projectName: string
9
+ isGit: boolean
10
+ repoName: string | null
11
+ branch: string | null
12
+ modifiedAt: string
13
+ sessionCount: number
14
+ }
15
+
16
+ export type GitInfo = {
17
+ branch: string | null
18
+ repoName: string | null
19
+ workDir: string
20
+ changedFiles: number
21
+ }
22
+
23
+ export type SessionTask = {
24
+ id: string
25
+ subject: string
26
+ status: 'pending' | 'in_progress' | 'completed'
27
+ }
28
+
29
+ export class AdapterHttpClient {
30
+ readonly httpBaseUrl: string
31
+ private readonly allowedProjectRoots: string[]
32
+ /** Default timeout for HTTP requests (30 seconds) */
33
+ private static readonly DEFAULT_TIMEOUT_MS = 30_000
34
+
35
+ constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
36
+ this.httpBaseUrl = wsUrl
37
+ .replace(/^ws:/, 'http:')
38
+ .replace(/^wss:/, 'https:')
39
+ .replace(/\/$/, '')
40
+ this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
41
+ .map(resolveExistingProjectPath)
42
+ .filter((value): value is string => Boolean(value))
43
+ }
44
+
45
+ /** Create an AbortController with timeout */
46
+ private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
47
+ controller: AbortController
48
+ timer: ReturnType<typeof setTimeout>
49
+ } {
50
+ const controller = new AbortController()
51
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
52
+ return { controller, timer }
53
+ }
54
+
55
+ async createSession(workDir: string): Promise<string> {
56
+ const { controller, timer } = this.createTimeoutController()
57
+ try {
58
+ const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ workDir }),
62
+ signal: controller.signal,
63
+ })
64
+ if (!res.ok) {
65
+ const err = await res.json().catch(() => ({ message: res.statusText }))
66
+ throw new Error(`Failed to create session: ${(err as any).message}`)
67
+ }
68
+ const data = (await res.json()) as { sessionId: string }
69
+ return data.sessionId
70
+ } finally {
71
+ clearTimeout(timer)
72
+ }
73
+ }
74
+
75
+ async listRecentProjects(): Promise<RecentProject[]> {
76
+ const { controller, timer } = this.createTimeoutController()
77
+ try {
78
+ const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
79
+ signal: controller.signal,
80
+ })
81
+ if (!res.ok) {
82
+ throw new Error(`Failed to list projects: ${res.statusText}`)
83
+ }
84
+ const data = (await res.json()) as { projects: RecentProject[] }
85
+ return data.projects
86
+ } finally {
87
+ clearTimeout(timer)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Match a project by index (1-based) or fuzzy name from recent projects.
93
+ * Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
94
+ */
95
+ async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
96
+ const directPath = resolveExistingProjectPath(query)
97
+ if (directPath) {
98
+ if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
99
+ return {}
100
+ }
101
+
102
+ return {
103
+ project: {
104
+ projectPath: directPath,
105
+ realPath: directPath,
106
+ projectName: path.basename(directPath) || directPath,
107
+ isGit: fs.existsSync(path.join(directPath, '.git')),
108
+ repoName: null,
109
+ branch: null,
110
+ modifiedAt: new Date().toISOString(),
111
+ sessionCount: 0,
112
+ },
113
+ }
114
+ }
115
+
116
+ const projects = await this.listRecentProjects()
117
+
118
+ // Try as 1-based index
119
+ const num = parseInt(query, 10)
120
+ if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
121
+ return { project: projects[num - 1] }
122
+ }
123
+
124
+ const q = query.toLowerCase()
125
+
126
+ // Exact project name match
127
+ const exact = projects.find(p => p.projectName.toLowerCase() === q)
128
+ if (exact) return { project: exact }
129
+
130
+ // Fuzzy: name or path contains query
131
+ const matches = projects.filter(p =>
132
+ p.projectName.toLowerCase().includes(q) ||
133
+ p.realPath.toLowerCase().includes(q)
134
+ )
135
+ if (matches.length === 1) return { project: matches[0] }
136
+ if (matches.length > 1) return { ambiguous: matches }
137
+
138
+ return {}
139
+ }
140
+
141
+ async getGitInfo(sessionId: string): Promise<GitInfo> {
142
+ const { controller, timer } = this.createTimeoutController()
143
+ try {
144
+ const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
145
+ signal: controller.signal,
146
+ })
147
+ if (!res.ok) {
148
+ const err = await res.json().catch(() => ({ message: res.statusText }))
149
+ throw new Error(`Failed to load git info: ${(err as any).message}`)
150
+ }
151
+ return (await res.json()) as GitInfo
152
+ } finally {
153
+ clearTimeout(timer)
154
+ }
155
+ }
156
+
157
+ async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
158
+ const { controller, timer } = this.createTimeoutController()
159
+ try {
160
+ const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
161
+ signal: controller.signal,
162
+ })
163
+ if (!res.ok) {
164
+ if (res.status === 404) return []
165
+ const err = await res.json().catch(() => ({ message: res.statusText }))
166
+ throw new Error(`Failed to load tasks: ${(err as any).message}`)
167
+ }
168
+ const data = (await res.json()) as { tasks?: SessionTask[] }
169
+ return Array.isArray(data.tasks) ? data.tasks : []
170
+ } finally {
171
+ clearTimeout(timer)
172
+ }
173
+ }
174
+ }
175
+
176
+ function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
177
+ if (roots.length === 0) return false
178
+
179
+ for (const root of roots) {
180
+ const relative = path.relative(root, target)
181
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
182
+ return true
183
+ }
184
+ }
185
+
186
+ return false
187
+ }
188
+
189
+ function resolveExistingProjectPath(query: string): string | null {
190
+ const trimmed = query.trim()
191
+ if (!trimmed) return null
192
+
193
+ const expanded = trimmed === '~'
194
+ ? os.homedir()
195
+ : trimmed.startsWith('~/')
196
+ ? path.join(os.homedir(), trimmed.slice(2))
197
+ : trimmed
198
+
199
+ if (!path.isAbsolute(expanded)) return null
200
+
201
+ try {
202
+ const realPath = fs.realpathSync(expanded)
203
+ return fs.statSync(realPath).isDirectory() ? realPath : null
204
+ } catch {
205
+ return null
206
+ }
207
+ }
adapters/common/message-buffer.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 流式消息缓冲
3
+ *
4
+ * 将 content_delta 累积后按时间窗口或字符数批量 flush。
5
+ * 用于 Telegram editMessage / 飞书流式卡片更新。
6
+ */
7
+
8
+ export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
9
+
10
+ const DEFAULT_INTERVAL_MS = 500
11
+ const DEFAULT_CHAR_THRESHOLD = 200
12
+
13
+ export class MessageBuffer {
14
+ private buffer = ''
15
+ private timer: ReturnType<typeof setTimeout> | null = null
16
+ private flushing = false
17
+ private pendingComplete = false
18
+ private activeFlush: Promise<void> | null = null
19
+
20
+ constructor(
21
+ private onFlush: FlushCallback,
22
+ private intervalMs = DEFAULT_INTERVAL_MS,
23
+ private charThreshold = DEFAULT_CHAR_THRESHOLD,
24
+ ) {}
25
+
26
+ /** Append text delta. Triggers flush if threshold reached. */
27
+ append(text: string): void {
28
+ this.buffer += text
29
+ if (this.buffer.length >= this.charThreshold) {
30
+ this.scheduleFlush()
31
+ } else if (!this.timer) {
32
+ this.timer = setTimeout(() => this.flush(false), this.intervalMs)
33
+ }
34
+ }
35
+
36
+ /** Immediately flush all remaining content (called on message_complete). */
37
+ async complete(): Promise<void> {
38
+ if (this.timer) {
39
+ clearTimeout(this.timer)
40
+ this.timer = null
41
+ }
42
+ if (this.flushing) {
43
+ // A flush is in-flight; mark pending so it fires after current flush finishes
44
+ this.pendingComplete = true
45
+ await this.activeFlush
46
+ return
47
+ }
48
+ await this.flush(true)
49
+ }
50
+
51
+ /** Reset the buffer for a new message. */
52
+ reset(): void {
53
+ this.buffer = ''
54
+ this.pendingComplete = false
55
+ if (this.timer) {
56
+ clearTimeout(this.timer)
57
+ this.timer = null
58
+ }
59
+ }
60
+
61
+ private scheduleFlush(): void {
62
+ if (this.timer) {
63
+ clearTimeout(this.timer)
64
+ this.timer = null
65
+ }
66
+ queueMicrotask(() => this.flush(false))
67
+ }
68
+
69
+ private async flush(isComplete: boolean): Promise<void> {
70
+ if (this.timer) {
71
+ clearTimeout(this.timer)
72
+ this.timer = null
73
+ }
74
+ if (this.flushing) {
75
+ await this.activeFlush
76
+ return
77
+ }
78
+ if (this.buffer.length === 0) return
79
+
80
+ this.flushing = true
81
+ const text = this.buffer
82
+ this.buffer = ''
83
+ this.activeFlush = (async () => {
84
+ try {
85
+ await this.onFlush(text, isComplete)
86
+ } catch (err) {
87
+ console.error('[MessageBuffer] Flush error:', err)
88
+ } finally {
89
+ this.flushing = false
90
+ this.activeFlush = null
91
+ // If complete() was called while we were flushing, do the final flush now.
92
+ if (this.pendingComplete) {
93
+ this.pendingComplete = false
94
+ await this.flush(true)
95
+ }
96
+ }
97
+ })()
98
+ await this.activeFlush
99
+ }
100
+ }
adapters/common/message-dedup.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 消息去重
3
+ *
4
+ * 防止 WebSocket 重连等场景下消息重复处理。
5
+ * 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
6
+ */
7
+
8
+ const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
9
+ const DEFAULT_MAX_ENTRIES = 5000
10
+ const SWEEP_INTERVAL_MS = 60_000 // 1 minute
11
+
12
+ export class MessageDedup {
13
+ private store = new Map<string, number>()
14
+ private sweepTimer: ReturnType<typeof setInterval>
15
+
16
+ constructor(
17
+ private ttlMs = DEFAULT_TTL_MS,
18
+ private maxEntries = DEFAULT_MAX_ENTRIES,
19
+ ) {
20
+ this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
21
+ }
22
+
23
+ /** Returns true if this is a NEW message, false if duplicate. */
24
+ tryRecord(id: string): boolean {
25
+ const now = Date.now()
26
+ const existing = this.store.get(id)
27
+
28
+ if (existing !== undefined && now - existing < this.ttlMs) {
29
+ return false // duplicate
30
+ }
31
+
32
+ // Evict oldest if at capacity
33
+ if (this.store.size >= this.maxEntries) {
34
+ const oldest = this.store.keys().next().value
35
+ if (oldest !== undefined) this.store.delete(oldest)
36
+ }
37
+
38
+ this.store.set(id, now)
39
+ return true
40
+ }
41
+
42
+ private sweep(): void {
43
+ const now = Date.now()
44
+ for (const [key, ts] of this.store) {
45
+ if (now - ts >= this.ttlMs) {
46
+ this.store.delete(key)
47
+ } else {
48
+ break // Map preserves insertion order; once fresh, rest is fresh
49
+ }
50
+ }
51
+ }
52
+
53
+ destroy(): void {
54
+ clearInterval(this.sweepTimer)
55
+ this.store.clear()
56
+ }
57
+ }
adapters/common/pairing.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 配对核心逻辑
3
+ *
4
+ * - generatePairingCode(): 生成 6 位安全配对码
5
+ * - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
6
+ * - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
7
+ */
8
+
9
+ import * as fs from 'node:fs'
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
12
+ import * as crypto from 'node:crypto'
13
+ import type { PairedUser, PairingState } from './config.js'
14
+
15
+ const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
16
+ export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
17
+
18
+ // 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
19
+ const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
20
+ const RATE_LIMIT_MAX_ATTEMPTS = 5
21
+ const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
22
+
23
+ function isRateLimited(userId: string | number): boolean {
24
+ const key = String(userId)
25
+ const record = failedAttempts.get(key)
26
+ if (!record) return false
27
+ if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
28
+ failedAttempts.delete(key)
29
+ return false
30
+ }
31
+ return record.count >= RATE_LIMIT_MAX_ATTEMPTS
32
+ }
33
+
34
+ function recordFailedAttempt(userId: string | number): void {
35
+ const key = String(userId)
36
+ const record = failedAttempts.get(key)
37
+ if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
38
+ failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
39
+ } else {
40
+ record.count++
41
+ }
42
+ }
43
+ const CODE_LENGTH = 6
44
+ const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
45
+
46
+ function getConfigPath(): string {
47
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
48
+ return path.join(configDir, 'adapters.json')
49
+ }
50
+
51
+ function readConfigFile(): Record<string, any> {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
54
+ } catch {
55
+ return {}
56
+ }
57
+ }
58
+
59
+ function writeConfigFile(data: Record<string, any>): void {
60
+ const filePath = getConfigPath()
61
+ const dir = path.dirname(filePath)
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
63
+ const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}`
64
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 })
65
+ fs.renameSync(tmp, filePath)
66
+ }
67
+
68
+ export function generatePairingCode(): string {
69
+ let code = ''
70
+ for (let i = 0; i < CODE_LENGTH; i++) {
71
+ code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
72
+ }
73
+ return code
74
+ }
75
+
76
+ /** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
77
+ export function isPaired(
78
+ platform: ImPlatform,
79
+ userId: string | number,
80
+ config: Record<string, any>,
81
+ ): boolean {
82
+ const platformConfig = config[platform] ?? {}
83
+ const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
84
+ const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
85
+
86
+ // allowedUsers 非空时检查
87
+ if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
88
+ // 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
89
+ if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
90
+
91
+ return pairedUsers.some((p) => String(p.userId) === String(userId))
92
+ }
93
+
94
+ /**
95
+ * 尝试配对:验证消息文本是否匹配当前有效配对码。
96
+ * 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
97
+ */
98
+ export function tryPair(
99
+ messageText: string,
100
+ senderInfo: { userId: string | number; displayName: string },
101
+ platform: ImPlatform,
102
+ ): boolean {
103
+ const file = readConfigFile()
104
+ const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
105
+
106
+ // 速率限制检查
107
+ if (isRateLimited(senderInfo.userId)) return false
108
+
109
+ // 检查配对码是否有效
110
+ if (!pairing.code || !pairing.expiresAt) return false
111
+ if (Date.now() > pairing.expiresAt) return false
112
+
113
+ // 比较(忽略大小写和空格)
114
+ const input = messageText.trim().toUpperCase()
115
+ if (input !== pairing.code.toUpperCase()) {
116
+ recordFailedAttempt(senderInfo.userId)
117
+ return false
118
+ }
119
+
120
+ // 配对成功:写入 pairedUsers
121
+ const platformConfig = file[platform] ?? {}
122
+ const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
123
+
124
+ // 避免重复
125
+ const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
126
+ if (!exists) {
127
+ pairedUsers.push({
128
+ userId: senderInfo.userId,
129
+ displayName: senderInfo.displayName,
130
+ pairedAt: Date.now(),
131
+ })
132
+ }
133
+
134
+ // 更新 config
135
+ file[platform] = { ...platformConfig, pairedUsers }
136
+ file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
137
+ writeConfigFile(file)
138
+
139
+ return true
140
+ }
141
+
142
+ /** 统一的用户授权检查(供各 adapter 调用) */
143
+ export function isAllowedUser(platform: ImPlatform, userId: string | number): boolean {
144
+ try {
145
+ const cfgFile = readConfigFile()
146
+ return isPaired(platform, userId, cfgFile)
147
+ } catch {
148
+ return false
149
+ }
150
+ }
adapters/common/permission.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type PermissionDecision = {
2
+ requestId: string
3
+ allowed: boolean
4
+ rule?: 'always'
5
+ }
6
+
7
+ function getSinglePendingRequestId(requestIds?: Iterable<string> | null): string | null {
8
+ if (!requestIds) return null
9
+ const ids = Array.from(requestIds)
10
+ return ids.length === 1 ? ids[0]! : null
11
+ }
12
+
13
+ export function parsePermissionCommand(
14
+ text: string,
15
+ pendingRequestIds?: Iterable<string> | null,
16
+ ): PermissionDecision | null {
17
+ const trimmed = text.trim()
18
+ const match = text.trim().match(/^\/(allow|always|allow-always|deny)\s+(\S+)/i)
19
+ if (match) {
20
+ const action = match[1]!.toLowerCase()
21
+ const requestId = match[2]!
22
+ if (action === 'deny') return { requestId, allowed: false }
23
+ if (action === 'always' || action === 'allow-always') return { requestId, allowed: true, rule: 'always' }
24
+ return { requestId, allowed: true }
25
+ }
26
+
27
+ const requestId = getSinglePendingRequestId(pendingRequestIds)
28
+ if (!requestId) return null
29
+
30
+ const shortcut = trimmed.toLowerCase()
31
+ if (['1', '/1', 'allow', '/allow', 'y', 'yes', '允许', '允许一次', '同意', '批准'].includes(shortcut)) {
32
+ return { requestId, allowed: true }
33
+ }
34
+ if (['2', '/2', 'always', '/always', 'allow-always', '/allow-always', '永久允许', '一直允许'].includes(shortcut)) {
35
+ return { requestId, allowed: true, rule: 'always' }
36
+ }
37
+ if (['3', '/3', 'deny', '/deny', 'n', 'no', '拒绝', '不允许', '否'].includes(shortcut)) {
38
+ return { requestId, allowed: false }
39
+ }
40
+
41
+ return null
42
+ }
43
+
44
+ export function parsePermitCallbackData(data: string): PermissionDecision | null {
45
+ const parts = data.split(':')
46
+ if (parts.length !== 3 || parts[0] !== 'permit' || !parts[1]) return null
47
+
48
+ switch (parts[2]) {
49
+ case 'yes':
50
+ return { requestId: parts[1], allowed: true }
51
+ case 'always':
52
+ return { requestId: parts[1], allowed: true, rule: 'always' }
53
+ case 'no':
54
+ return { requestId: parts[1], allowed: false }
55
+ default:
56
+ return null
57
+ }
58
+ }
59
+
60
+ export function formatPermissionInstructions(requestId: string): string {
61
+ return [
62
+ '回复 1 允许一次,2 永久允许,3 拒绝。',
63
+ `也可回复 /allow ${requestId}、/always ${requestId}、/deny ${requestId}。`,
64
+ ].join('\n')
65
+ }
66
+
67
+ export function formatPermissionDecisionStatus(decision: Pick<PermissionDecision, 'allowed' | 'rule'>): string {
68
+ if (!decision.allowed) return '❌ 已拒绝'
69
+ return decision.rule === 'always' ? '♾️ 已永久允许' : '✅ 已允许'
70
+ }
adapters/common/session-store.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import * as os from 'node:os'
4
+
5
+ export type SessionEntry = {
6
+ sessionId: string
7
+ workDir: string
8
+ updatedAt: number
9
+ }
10
+
11
+ type StoreData = Record<string, SessionEntry>
12
+
13
+ function getDefaultPath(): string {
14
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
15
+ return path.join(configDir, 'adapter-sessions.json')
16
+ }
17
+
18
+ export class SessionStore {
19
+ private data: StoreData
20
+ private filePath: string
21
+
22
+ constructor(filePath?: string) {
23
+ this.filePath = filePath ?? getDefaultPath()
24
+ this.data = this.load()
25
+ }
26
+
27
+ get(chatId: string): SessionEntry | null {
28
+ this.refresh()
29
+ return this.data[chatId] ?? null
30
+ }
31
+
32
+ set(chatId: string, sessionId: string, workDir: string): void {
33
+ this.refresh()
34
+ this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
35
+ this.save()
36
+ }
37
+
38
+ delete(chatId: string): void {
39
+ this.refresh()
40
+ delete this.data[chatId]
41
+ this.save()
42
+ }
43
+
44
+ deleteBySessionId(sessionId: string): string[] {
45
+ this.refresh()
46
+ const removed: string[] = []
47
+ for (const [chatId, entry] of Object.entries(this.data)) {
48
+ if (entry.sessionId !== sessionId) continue
49
+ delete this.data[chatId]
50
+ removed.push(chatId)
51
+ }
52
+ if (removed.length > 0) {
53
+ this.save()
54
+ }
55
+ return removed
56
+ }
57
+
58
+ listAll(): Array<{ chatId: string } & SessionEntry> {
59
+ this.refresh()
60
+ return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
61
+ }
62
+
63
+ private refresh(): void {
64
+ this.data = this.load()
65
+ }
66
+
67
+ private load(): StoreData {
68
+ try {
69
+ return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
70
+ } catch {
71
+ return {}
72
+ }
73
+ }
74
+
75
+ private save(): void {
76
+ const dir = path.dirname(this.filePath)
77
+ fs.mkdirSync(dir, { recursive: true })
78
+ const tmp = `${this.filePath}.tmp.${Date.now()}`
79
+ fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
80
+ fs.renameSync(tmp, this.filePath)
81
+ }
82
+ }
adapters/common/ws-bridge.ts ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WebSocket Bridge
3
+ *
4
+ * 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。
5
+ * 管理 chatId → sessionId 映射,自动重连,心跳。
6
+ */
7
+
8
+ import WebSocket from 'ws'
9
+
10
+ /** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef.
11
+ * The server will either (a) write base64 `data` to
12
+ * ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or
13
+ * (b) read `path` from disk and inject `@"path"` into the prompt. */
14
+ export type AttachmentRef = {
15
+ type: 'file' | 'image'
16
+ name?: string
17
+ path?: string
18
+ data?: string // base64 payload (images)
19
+ mimeType?: string
20
+ }
21
+
22
+ /** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */
23
+ export type ServerMessage = {
24
+ type: string
25
+ [key: string]: any
26
+ }
27
+
28
+ /** Callback for server messages */
29
+ export type MessageHandler = (msg: ServerMessage) => void
30
+
31
+ type Session = {
32
+ sessionId: string
33
+ ws: WebSocket
34
+ reconnectAttempts: number
35
+ reconnectTimer: ReturnType<typeof setTimeout> | null
36
+ }
37
+
38
+ const HEARTBEAT_INTERVAL_MS = 30_000
39
+ const RECONNECT_BASE_MS = 1000
40
+ const RECONNECT_MAX_MS = 30_000
41
+ const MAX_RECONNECT_ATTEMPTS = 10
42
+
43
+ export class WsBridge {
44
+ private sessions = new Map<string, Session>()
45
+ /** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
46
+ private handlers = new Map<string, MessageHandler>()
47
+ /** Per-chat FIFO queue of in-flight handler promises.
48
+ * Ensures an async handler for message N completes before handler for N+1
49
+ * starts, preventing state races at `await` points. */
50
+ private handlerChains = new Map<string, Promise<void>>()
51
+ private serverUrl: string
52
+ private platform: string
53
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
54
+ private destroyed = false
55
+
56
+ constructor(serverUrl: string, platform: string) {
57
+ this.serverUrl = serverUrl.replace(/\/$/, '')
58
+ this.platform = platform
59
+ this.startHeartbeat()
60
+ }
61
+
62
+ /** Connect to a session with a known sessionId. Returns false if already connected. */
63
+ connectSession(chatId: string, sessionId: string): boolean {
64
+ const existing = this.sessions.get(chatId)
65
+ if (existing && existing.ws.readyState === WebSocket.OPEN) {
66
+ return false
67
+ }
68
+ this.connect(chatId, sessionId)
69
+ return true
70
+ }
71
+
72
+ /** Send a user message to the session bound to chatId. */
73
+ sendUserMessage(
74
+ chatId: string,
75
+ content: string,
76
+ attachments?: AttachmentRef[],
77
+ ): boolean {
78
+ const payload: Record<string, unknown> = { type: 'user_message', content }
79
+ if (attachments && attachments.length > 0) {
80
+ payload.attachments = attachments
81
+ }
82
+ return this.send(chatId, payload)
83
+ }
84
+
85
+ /** Respond to a permission request.
86
+ *
87
+ * @param rule - optional rule name to make the permission persistent.
88
+ * Currently the server supports `'always'`, which uses the CLI's
89
+ * permission_suggestions to produce updatedPermissions so the same
90
+ * tool call won't prompt again in this session. Omit for one-shot allow. */
91
+ sendPermissionResponse(
92
+ chatId: string,
93
+ requestId: string,
94
+ allowed: boolean,
95
+ rule?: string,
96
+ ): boolean {
97
+ const message: Record<string, unknown> = {
98
+ type: 'permission_response',
99
+ requestId,
100
+ allowed,
101
+ }
102
+ if (rule) message.rule = rule
103
+ return this.send(chatId, message)
104
+ }
105
+
106
+ /** Stop the current generation. */
107
+ sendStopGeneration(chatId: string): boolean {
108
+ return this.send(chatId, { type: 'stop_generation' })
109
+ }
110
+
111
+ /** Register (or replace) the handler for server messages on a specific chatId. */
112
+ onServerMessage(chatId: string, handler: MessageHandler): void {
113
+ this.handlers.set(chatId, handler)
114
+ }
115
+
116
+ /** Reset session for a chatId (e.g. /new command). */
117
+ resetSession(chatId: string): void {
118
+ const session = this.sessions.get(chatId)
119
+ if (session) {
120
+ if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
121
+ session.ws.close(1000, 'session reset')
122
+ this.sessions.delete(chatId)
123
+ }
124
+ this.handlers.delete(chatId)
125
+ this.handlerChains.delete(chatId)
126
+ }
127
+
128
+ /** Has a session (connected or handler registered) for chatId. */
129
+ hasSession(chatId: string): boolean {
130
+ return this.sessions.has(chatId) || this.handlers.has(chatId)
131
+ }
132
+
133
+ /** Destroy all sessions. */
134
+ destroy(): void {
135
+ this.destroyed = true
136
+ if (this.heartbeatTimer) {
137
+ clearInterval(this.heartbeatTimer)
138
+ this.heartbeatTimer = null
139
+ }
140
+ for (const [, session] of this.sessions) {
141
+ if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
142
+ session.ws.close(1000, 'bridge destroyed')
143
+ }
144
+ this.sessions.clear()
145
+ this.handlers.clear()
146
+ this.handlerChains.clear()
147
+ }
148
+
149
+ // ------- internal -------
150
+
151
+ private connect(chatId: string, sessionId: string): void {
152
+ const url = `${this.serverUrl}/ws/${sessionId}`
153
+ const ws = new WebSocket(url)
154
+
155
+ // Cancel any pending reconnect timer for this chatId
156
+ const prev = this.sessions.get(chatId)
157
+ if (prev) {
158
+ if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
159
+ prev.ws.removeAllListeners()
160
+ }
161
+
162
+ const session: Session = {
163
+ sessionId,
164
+ ws,
165
+ reconnectAttempts: prev?.reconnectAttempts ?? 0,
166
+ reconnectTimer: null,
167
+ }
168
+ this.sessions.set(chatId, session)
169
+
170
+ ws.on('open', () => {
171
+ console.log(`[WsBridge] Connected: ${sessionId}`)
172
+ session.reconnectAttempts = 0
173
+ })
174
+
175
+ ws.on('message', (raw) => {
176
+ let msg: ServerMessage
177
+ try {
178
+ msg = JSON.parse(raw.toString())
179
+ } catch (err) {
180
+ console.error('[WsBridge] Parse error:', err)
181
+ return
182
+ }
183
+ if (msg.type === 'pong') return
184
+ const handler = this.handlers.get(chatId)
185
+ if (!handler) return
186
+
187
+ // Serialize per-chat handler calls: chain each message onto the previous
188
+ // one so a slow handler (e.g. one awaiting im.message.create) fully
189
+ // finishes before the next message's handler runs. This prevents state
190
+ // races where a later message reads stale map entries set up by an
191
+ // earlier-but-still-in-flight handler.
192
+ const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
193
+ const next = prev
194
+ .catch(() => {}) // upstream errors must not poison the chain
195
+ .then(() => Promise.resolve().then(() => handler(msg)))
196
+ .catch((err) => {
197
+ console.error(`[WsBridge] Handler error on ${chatId}:`, err)
198
+ })
199
+ this.handlerChains.set(chatId, next)
200
+ })
201
+
202
+ ws.on('close', (code, reason) => {
203
+ console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
204
+ if (this.sessions.get(chatId) !== session) return
205
+ if (code === 1000) {
206
+ if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
207
+ this.sessions.delete(chatId)
208
+ this.handlers.delete(chatId)
209
+ this.handlerChains.delete(chatId)
210
+ return
211
+ }
212
+ this.scheduleReconnect(chatId, sessionId)
213
+ })
214
+
215
+ ws.on('error', (err) => {
216
+ console.error(`[WsBridge] Error on ${sessionId}:`, err.message)
217
+ })
218
+ }
219
+
220
+ /** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
221
+ waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
222
+ const session = this.sessions.get(chatId)
223
+ if (!session) return Promise.resolve(false)
224
+ if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
225
+ return new Promise((resolve) => {
226
+ const timer = setTimeout(() => {
227
+ cleanup()
228
+ resolve(false)
229
+ }, timeoutMs)
230
+ const onOpen = () => { cleanup(); resolve(true) }
231
+ const onError = () => { cleanup(); resolve(false) }
232
+ const onClose = () => { cleanup(); resolve(false) }
233
+ const cleanup = () => {
234
+ clearTimeout(timer)
235
+ session.ws.removeListener('open', onOpen)
236
+ session.ws.removeListener('error', onError)
237
+ session.ws.removeListener('close', onClose)
238
+ }
239
+ session.ws.once('open', onOpen)
240
+ session.ws.once('error', onError)
241
+ session.ws.once('close', onClose)
242
+ })
243
+ }
244
+
245
+ private send(chatId: string, message: Record<string, unknown>): boolean {
246
+ const session = this.sessions.get(chatId)
247
+ if (!session || session.ws.readyState !== WebSocket.OPEN) {
248
+ console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
249
+ return false
250
+ }
251
+ session.ws.send(JSON.stringify(message))
252
+ return true
253
+ }
254
+
255
+ private scheduleReconnect(chatId: string, sessionId: string): void {
256
+ if (this.destroyed) return
257
+ const session = this.sessions.get(chatId)
258
+ if (!session) return
259
+ if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
260
+ console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`)
261
+ this.sessions.delete(chatId)
262
+ this.handlers.delete(chatId)
263
+ return
264
+ }
265
+
266
+ session.reconnectAttempts++
267
+ const delay = Math.min(
268
+ RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1),
269
+ RECONNECT_MAX_MS,
270
+ )
271
+ console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
272
+ session.reconnectTimer = setTimeout(() => {
273
+ if (this.destroyed) return
274
+ if (this.sessions.get(chatId)?.sessionId === sessionId) {
275
+ this.connect(chatId, sessionId)
276
+ }
277
+ }, delay)
278
+ }
279
+
280
+ private startHeartbeat(): void {
281
+ this.heartbeatTimer = setInterval(() => {
282
+ for (const [, session] of this.sessions) {
283
+ if (session.ws.readyState === WebSocket.OPEN) {
284
+ session.ws.send(JSON.stringify({ type: 'ping' }))
285
+ }
286
+ }
287
+ }, HEARTBEAT_INTERVAL_MS)
288
+ }
289
+ }
adapters/dingtalk/__tests__/ai-card.test.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { buildDeliverBody, DingTalkAiCardService } from '../ai-card.js'
3
+
4
+ describe('DingTalk AI Card streaming', () => {
5
+ const originalFetch = globalThis.fetch
6
+ const calls: Array<{ url: string; method: string; body: any }> = []
7
+
8
+ beforeEach(() => {
9
+ calls.length = 0
10
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
11
+ calls.push({
12
+ url: String(url),
13
+ method: init?.method ?? 'GET',
14
+ body: init?.body ? JSON.parse(String(init.body)) : null,
15
+ })
16
+ return new Response('{}', { status: 200 })
17
+ }) as any
18
+ })
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch
22
+ })
23
+
24
+ it('builds the official IM_ROBOT deliver payload', () => {
25
+ expect(buildDeliverBody('card-1', { type: 'user', userId: 'staff-1' }, 'robot-1')).toMatchObject({
26
+ outTrackId: 'card-1',
27
+ openSpaceId: 'dtv1.card//IM_ROBOT.staff-1',
28
+ imRobotOpenDeliverModel: {
29
+ spaceType: 'IM_ROBOT',
30
+ robotCode: 'robot-1',
31
+ },
32
+ })
33
+ })
34
+
35
+ it('creates, streams, and finishes an AI card', async () => {
36
+ const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1')
37
+ const card = await service.createForTarget({ type: 'user', userId: 'staff-1' })
38
+
39
+ expect(card?.cardInstanceId.startsWith('card_')).toBe(true)
40
+ expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
41
+ 'POST /v1.0/card/instances',
42
+ 'POST /v1.0/card/instances/deliver',
43
+ ])
44
+
45
+ await service.stream(card!, 'Hello', false)
46
+ expect(calls.at(-2)?.body.cardData.cardParamMap.flowStatus).toBe('2')
47
+ expect(new URL(calls.at(-1)!.url).pathname).toBe('/v1.0/card/streaming')
48
+ expect(calls.at(-1)?.body).toMatchObject({
49
+ key: 'msgContent',
50
+ content: 'Hello',
51
+ isFull: true,
52
+ isFinalize: false,
53
+ })
54
+
55
+ calls.length = 0
56
+ await service.finish(card!, 'Final')
57
+ expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
58
+ 'PUT /v1.0/card/streaming',
59
+ 'PUT /v1.0/card/instances',
60
+ ])
61
+ expect(calls[0]!.body.isFinalize).toBe(true)
62
+ expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3')
63
+ })
64
+
65
+ it('times out a hung card streaming request', async () => {
66
+ const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
67
+ process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20'
68
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
69
+ calls.push({
70
+ url: String(url),
71
+ method: init?.method ?? 'GET',
72
+ body: init?.body ? JSON.parse(String(init.body)) : null,
73
+ })
74
+ return await new Promise<Response>((_, reject) => {
75
+ init?.signal?.addEventListener('abort', () => {
76
+ reject(new DOMException('aborted', 'AbortError'))
77
+ })
78
+ })
79
+ }) as any
80
+
81
+ try {
82
+ const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1')
83
+ const card = {
84
+ cardInstanceId: 'card-hung',
85
+ accessToken: 'token-1',
86
+ tokenExpireTime: Date.now() + 60_000,
87
+ inputingStarted: true,
88
+ }
89
+
90
+ await expect(service.stream(card, 'Hello', false)).rejects.toThrow(
91
+ 'PUT /v1.0/card/streaming timed out after 20ms',
92
+ )
93
+ } finally {
94
+ if (previousTimeout === undefined) {
95
+ delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
96
+ } else {
97
+ process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout
98
+ }
99
+ }
100
+ })
101
+ })
adapters/dingtalk/__tests__/helpers.test.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ extractDingTalkAttachments,
4
+ extractDingTalkText,
5
+ getDingTalkChatId,
6
+ getDingTalkSenderId,
7
+ isDingTalkDirectMessage,
8
+ parseDingTalkPayload,
9
+ } from '../helpers.js'
10
+
11
+ describe('DingTalk helpers', () => {
12
+ it('parses robot payload JSON safely', () => {
13
+ expect(parseDingTalkPayload('{"msgtype":"text"}')?.msgtype).toBe('text')
14
+ expect(parseDingTalkPayload('not-json')).toBeNull()
15
+ })
16
+
17
+ it('extracts sender and chat ids for direct messages', () => {
18
+ const data = {
19
+ conversationType: '1',
20
+ senderStaffId: 'staff-1',
21
+ conversationId: 'cid-1',
22
+ }
23
+
24
+ expect(isDingTalkDirectMessage(data)).toBe(true)
25
+ expect(getDingTalkSenderId(data)).toBe('staff-1')
26
+ expect(getDingTalkChatId(data)).toBe('dingtalk:dm:staff-1')
27
+ })
28
+
29
+ it('extracts text from common DingTalk content shapes', () => {
30
+ expect(extractDingTalkText({ text: { content: ' hello ' } })).toBe('hello')
31
+ expect(extractDingTalkText({ content: '{"text":"from content"}' })).toBe('from content')
32
+ expect(extractDingTalkText({
33
+ content: {
34
+ richText: [
35
+ { text: 'hello' },
36
+ { text: ' world' },
37
+ ],
38
+ },
39
+ })).toBe('hello world')
40
+ })
41
+
42
+ it('extracts image and file attachment candidates', () => {
43
+ expect(extractDingTalkAttachments({
44
+ msgtype: 'picture',
45
+ content: { pictureUrl: 'https://example.com/a.jpg', downloadCode: 'pic-code' },
46
+ })).toEqual([{ kind: 'image', url: 'https://example.com/a.jpg', downloadCode: 'pic-code' }])
47
+
48
+ expect(extractDingTalkAttachments({
49
+ msgtype: 'file',
50
+ content: '{"fileName":"report.pdf","downloadCode":"file-code"}',
51
+ })).toEqual([{ kind: 'file', downloadCode: 'file-code', fileName: 'report.pdf' }])
52
+
53
+ expect(extractDingTalkAttachments({
54
+ msgtype: 'richText',
55
+ content: { richText: [{ text: 'hi' }, { type: 'picture', downloadCode: 'rich-pic' }] },
56
+ })).toEqual([{ kind: 'image', url: undefined, downloadCode: 'rich-pic' }])
57
+ })
58
+ })
adapters/dingtalk/__tests__/permission-card.test.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ buildDingTalkPermissionCardParams,
4
+ parseDingTalkPermissionCardAction,
5
+ } from '../permission-card.js'
6
+
7
+ describe('DingTalk permission card helpers', () => {
8
+ it('builds template params with three permission actions', () => {
9
+ const params = buildDingTalkPermissionCardParams('Bash', { command: 'npm test' }, 'req-1')
10
+
11
+ expect(params.requestId).toBe('req-1')
12
+ expect(params.toolName).toBe('Bash')
13
+ expect(String(params.inputPreview)).toContain('npm test')
14
+ expect(JSON.parse(String(params.allowValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true })
15
+ expect(JSON.parse(String(params.alwaysValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true, rule: 'always' })
16
+ expect(JSON.parse(String(params.denyValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: false })
17
+ })
18
+
19
+ it('parses nested card private params', () => {
20
+ const action = parseDingTalkPermissionCardAction({
21
+ outTrackId: 'permission_req-1',
22
+ content: JSON.stringify({
23
+ cardPrivateData: {
24
+ params: {
25
+ action: 'permit',
26
+ requestId: 'req-1',
27
+ allowed: true,
28
+ rule: 'always',
29
+ },
30
+ },
31
+ }),
32
+ })
33
+
34
+ expect(action).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
35
+ })
36
+
37
+ it('parses compact callback values', () => {
38
+ expect(parseDingTalkPermissionCardAction({ actionValue: 'permit:req-2:no' })).toEqual({
39
+ requestId: 'req-2',
40
+ allowed: false,
41
+ })
42
+ })
43
+
44
+ it('ignores callbacks without permission action data', () => {
45
+ expect(parseDingTalkPermissionCardAction({ action: 'open_url', url: 'https://example.com' })).toBeNull()
46
+ })
47
+ })
adapters/dingtalk/__tests__/stream-state.test.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { MessageBuffer } from '../../common/message-buffer.js'
3
+ import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from '../stream-state.js'
4
+
5
+ describe('DingTalk streaming state', () => {
6
+ it('drops the active AI card stream when permission interrupts output ordering', async () => {
7
+ let resetCalled = false
8
+ const buffer = new MessageBuffer(async () => {}, 100, 1000)
9
+ const originalReset = buffer.reset.bind(buffer)
10
+ buffer.reset = () => {
11
+ resetCalled = true
12
+ originalReset()
13
+ }
14
+
15
+ const state = {
16
+ aiCardBuffers: new Map([['chat-1', buffer]]),
17
+ streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
18
+ streamingCardText: new Map([['chat-1', 'pre-permission text']]),
19
+ }
20
+
21
+ resetDingTalkStreamingState(state, 'chat-1')
22
+
23
+ expect(resetCalled).toBe(true)
24
+ expect(state.aiCardBuffers.has('chat-1')).toBe(false)
25
+ expect(state.streamingCards.has('chat-1')).toBe(false)
26
+ expect(state.streamingCardText.has('chat-1')).toBe(false)
27
+ })
28
+
29
+ it('completes the existing stream before dropping it for a permission request', async () => {
30
+ const flushed: Array<{ text: string; complete: boolean }> = []
31
+ let finalized = false
32
+ const buffer = new MessageBuffer(
33
+ async (text, complete) => {
34
+ flushed.push({ text, complete })
35
+ },
36
+ 100,
37
+ 1000,
38
+ )
39
+ buffer.append('pre-permission text')
40
+
41
+ const state = {
42
+ aiCardBuffers: new Map([['chat-1', buffer]]),
43
+ streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
44
+ streamingCardText: new Map([['chat-1', 'already streamed']]),
45
+ finalize: async () => {
46
+ finalized = true
47
+ },
48
+ }
49
+
50
+ await finishAndResetDingTalkStreamingState(state, 'chat-1')
51
+
52
+ expect(flushed).toEqual([{ text: 'pre-permission text', complete: true }])
53
+ expect(finalized).toBe(true)
54
+ expect(state.aiCardBuffers.has('chat-1')).toBe(false)
55
+ expect(state.streamingCards.has('chat-1')).toBe(false)
56
+ expect(state.streamingCardText.has('chat-1')).toBe(false)
57
+ })
58
+
59
+ it('finalizes an already-flushed card even when the message buffer is empty', async () => {
60
+ let finalized = false
61
+ const buffer = new MessageBuffer(async () => {}, 100, 1000)
62
+ const state = {
63
+ aiCardBuffers: new Map([['chat-1', buffer]]),
64
+ streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
65
+ streamingCardText: new Map([['chat-1', 'already streamed']]),
66
+ finalize: async () => {
67
+ finalized = true
68
+ },
69
+ }
70
+
71
+ await finishAndResetDingTalkStreamingState(state, 'chat-1')
72
+
73
+ expect(finalized).toBe(true)
74
+ expect(state.aiCardBuffers.has('chat-1')).toBe(false)
75
+ expect(state.streamingCards.has('chat-1')).toBe(false)
76
+ expect(state.streamingCardText.has('chat-1')).toBe(false)
77
+ })
78
+ })
adapters/dingtalk/ai-card.ts ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DINGTALK_API = 'https://api.dingtalk.com'
2
+ const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'
3
+ const CARD_API_MAX_QPS = 20
4
+ const QPS_BACKOFF_DURATION_MS = 2_000
5
+ const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
6
+
7
+ const AICardStatus = {
8
+ INPUTING: '2',
9
+ FINISHED: '3',
10
+ } as const
11
+ type AICardFlowStatus = (typeof AICardStatus)[keyof typeof AICardStatus]
12
+
13
+ export type DingTalkAiCardTarget =
14
+ | { type: 'user'; userId: string }
15
+ | { type: 'group'; openConversationId: string }
16
+
17
+ export type DingTalkAiCardInstance = {
18
+ cardInstanceId: string
19
+ accessToken: string
20
+ tokenExpireTime: number
21
+ inputingStarted: boolean
22
+ }
23
+
24
+ export type DingTalkCreateCardOptions = {
25
+ cardTemplateId?: string
26
+ outTrackId?: string
27
+ cardParamMap?: Record<string, unknown>
28
+ callbackRouteKey?: string
29
+ }
30
+
31
+ type TokenProvider = () => Promise<string>
32
+
33
+ export class DingTalkAiCardService {
34
+ constructor(
35
+ private readonly getAccessToken: TokenProvider,
36
+ private readonly robotCode: string,
37
+ ) {}
38
+
39
+ async createForTarget(
40
+ target: DingTalkAiCardTarget,
41
+ options: DingTalkCreateCardOptions = {},
42
+ ): Promise<DingTalkAiCardInstance | null> {
43
+ try {
44
+ const token = await this.getAccessToken()
45
+ const cardInstanceId = options.outTrackId ?? `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
46
+ const createBody: Record<string, unknown> = {
47
+ cardTemplateId: options.cardTemplateId || AI_CARD_TEMPLATE_ID,
48
+ outTrackId: cardInstanceId,
49
+ cardData: {
50
+ cardParamMap: {
51
+ config: JSON.stringify({ autoLayout: true }),
52
+ ...options.cardParamMap,
53
+ },
54
+ },
55
+ callbackType: 'STREAM',
56
+ imGroupOpenSpaceModel: { supportForward: true },
57
+ imRobotOpenSpaceModel: { supportForward: true },
58
+ }
59
+ if (options.callbackRouteKey) createBody.callbackRouteKey = options.callbackRouteKey
60
+ await postJson('/v1.0/card/instances', token, createBody)
61
+
62
+ await postJson('/v1.0/card/instances/deliver', token, buildDeliverBody(cardInstanceId, target, this.robotCode))
63
+
64
+ return {
65
+ cardInstanceId,
66
+ accessToken: token,
67
+ tokenExpireTime: Date.now() + 2 * 60 * 60 * 1000,
68
+ inputingStarted: false,
69
+ }
70
+ } catch (err) {
71
+ console.warn('[DingTalk][AICard] create failed:', err instanceof Error ? err.message : err)
72
+ return null
73
+ }
74
+ }
75
+
76
+ async stream(card: DingTalkAiCardInstance, content: string, finished = false): Promise<void> {
77
+ await this.ensureValidToken(card)
78
+
79
+ if (!card.inputingStarted) {
80
+ await this.updateStatus(card, AICardStatus.INPUTING, content)
81
+ card.inputingStarted = true
82
+ }
83
+
84
+ await withCardRateLimit(() =>
85
+ putJson('/v1.0/card/streaming', card.accessToken, {
86
+ outTrackId: card.cardInstanceId,
87
+ guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
88
+ key: 'msgContent',
89
+ content: ensureTableBlankLines(content),
90
+ isFull: true,
91
+ isFinalize: finished,
92
+ isError: false,
93
+ }),
94
+ )
95
+ }
96
+
97
+ async finish(card: DingTalkAiCardInstance, content: string): Promise<void> {
98
+ await this.stream(card, content, true)
99
+ try {
100
+ await this.updateStatus(card, AICardStatus.FINISHED, ensureTableBlankLines(content))
101
+ } catch (err) {
102
+ console.warn('[DingTalk][AICard] finish status failed:', err instanceof Error ? err.message : err)
103
+ }
104
+ }
105
+
106
+ private async updateStatus(
107
+ card: DingTalkAiCardInstance,
108
+ flowStatus: AICardFlowStatus,
109
+ content: string,
110
+ ): Promise<void> {
111
+ const body: Record<string, unknown> = {
112
+ outTrackId: card.cardInstanceId,
113
+ cardData: {
114
+ cardParamMap: {
115
+ flowStatus,
116
+ msgContent: ensureTableBlankLines(content),
117
+ staticMsgContent: '',
118
+ sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
119
+ config: JSON.stringify({ autoLayout: true }),
120
+ },
121
+ },
122
+ }
123
+ if (flowStatus === AICardStatus.FINISHED) {
124
+ body.cardUpdateOptions = { updateCardDataByKey: true }
125
+ }
126
+
127
+ await withCardRateLimit(() =>
128
+ putJson('/v1.0/card/instances', card.accessToken, body),
129
+ )
130
+ }
131
+
132
+ private async ensureValidToken(card: DingTalkAiCardInstance): Promise<void> {
133
+ if (Date.now() <= card.tokenExpireTime - 5 * 60 * 1000) return
134
+ card.accessToken = await this.getAccessToken()
135
+ card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000
136
+ }
137
+ }
138
+
139
+ export function buildDeliverBody(
140
+ cardInstanceId: string,
141
+ target: DingTalkAiCardTarget,
142
+ robotCode: string,
143
+ ): Record<string, unknown> {
144
+ const base = { outTrackId: cardInstanceId, userIdType: 1 }
145
+ if (target.type === 'group') {
146
+ return {
147
+ ...base,
148
+ openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
149
+ imGroupOpenDeliverModel: {
150
+ robotCode,
151
+ },
152
+ }
153
+ }
154
+
155
+ return {
156
+ ...base,
157
+ openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
158
+ imRobotOpenDeliverModel: {
159
+ spaceType: 'IM_ROBOT',
160
+ robotCode,
161
+ extension: {
162
+ dynamicSummary: 'true',
163
+ },
164
+ },
165
+ }
166
+ }
167
+
168
+ async function postJson(path: string, token: string, body: Record<string, unknown>): Promise<void> {
169
+ await requestJson('POST', path, token, body)
170
+ }
171
+
172
+ async function putJson(path: string, token: string, body: Record<string, unknown>): Promise<void> {
173
+ await requestJson('PUT', path, token, body)
174
+ }
175
+
176
+ async function requestJson(
177
+ method: 'POST' | 'PUT',
178
+ path: string,
179
+ token: string,
180
+ body: Record<string, unknown>,
181
+ ): Promise<void> {
182
+ const controller = new AbortController()
183
+ const timeoutMs = getImCardRequestTimeoutMs()
184
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
185
+ try {
186
+ const res = await fetch(`${DINGTALK_API}${path}`, {
187
+ method,
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ 'x-acs-dingtalk-access-token': token,
191
+ },
192
+ body: JSON.stringify(body),
193
+ signal: controller.signal,
194
+ })
195
+ if (!res.ok) {
196
+ const text = await res.text().catch(() => '')
197
+ const err = new Error(`${method} ${path} failed: ${res.status} ${text}`)
198
+ ;(err as any).status = res.status
199
+ ;(err as any).body = text
200
+ throw err
201
+ }
202
+ } catch (err) {
203
+ if ((err as Error)?.name === 'AbortError') {
204
+ throw new Error(`${method} ${path} timed out after ${timeoutMs}ms`)
205
+ }
206
+ throw err
207
+ } finally {
208
+ clearTimeout(timer)
209
+ }
210
+ }
211
+
212
+ function getImCardRequestTimeoutMs(): number {
213
+ const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
214
+ const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
215
+ return Number.isFinite(parsed) && parsed > 0
216
+ ? parsed
217
+ : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
218
+ }
219
+
220
+ async function withCardRateLimit(fn: () => Promise<void>): Promise<void> {
221
+ await cardRateLimiter.waitForToken()
222
+ try {
223
+ await fn()
224
+ } catch (err) {
225
+ if (!isQpsLimitError(err)) throw err
226
+ cardRateLimiter.triggerBackoff()
227
+ await cardRateLimiter.waitForToken()
228
+ await fn()
229
+ }
230
+ }
231
+
232
+ function isQpsLimitError(err: unknown): boolean {
233
+ return (err as any)?.status === 403 && String((err as any)?.body ?? '').includes('QpsLimit')
234
+ }
235
+
236
+ const cardRateLimiter = {
237
+ tokens: CARD_API_MAX_QPS,
238
+ lastRefillTime: Date.now(),
239
+ backoffUntil: 0,
240
+ queueTail: Promise.resolve() as Promise<unknown>,
241
+
242
+ refill(): void {
243
+ const now = Date.now()
244
+ const elapsedSeconds = (now - this.lastRefillTime) / 1000
245
+ if (elapsedSeconds <= 0) return
246
+ this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS)
247
+ this.lastRefillTime = now
248
+ },
249
+
250
+ async waitForToken(): Promise<void> {
251
+ const prev = this.queueTail
252
+ let release!: () => void
253
+ this.queueTail = new Promise<void>((resolve) => {
254
+ release = resolve
255
+ })
256
+ try {
257
+ await prev.catch(() => {})
258
+ const now = Date.now()
259
+ if (now < this.backoffUntil) await sleep(this.backoffUntil - now)
260
+ this.refill()
261
+ if (this.tokens < 1) {
262
+ await sleep(Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000))
263
+ this.refill()
264
+ }
265
+ this.tokens -= 1
266
+ } finally {
267
+ release()
268
+ }
269
+ },
270
+
271
+ triggerBackoff(): void {
272
+ const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS
273
+ this.backoffUntil = backoffEnd
274
+ this.tokens = 0
275
+ this.lastRefillTime = backoffEnd
276
+ },
277
+ }
278
+
279
+ function sleep(ms: number): Promise<void> {
280
+ return new Promise((resolve) => setTimeout(resolve, ms))
281
+ }
282
+
283
+ function ensureTableBlankLines(text: string): string {
284
+ const lines = text.split('\n')
285
+ const result: string[] = []
286
+ const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/
287
+ const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/
288
+
289
+ for (let i = 0; i < lines.length; i++) {
290
+ const currentLine = lines[i] ?? ''
291
+ const nextLine = lines[i + 1] ?? ''
292
+ if (
293
+ tableRowRegex.test(currentLine) &&
294
+ nextLine.includes('|') &&
295
+ tableDividerRegex.test(nextLine) &&
296
+ i > 0 &&
297
+ lines[i - 1]?.trim() !== '' &&
298
+ !tableRowRegex.test(lines[i - 1] ?? '')
299
+ ) {
300
+ result.push('')
301
+ }
302
+ result.push(currentLine)
303
+ }
304
+ return result.join('\n')
305
+ }
adapters/dingtalk/helpers.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type DingTalkRobotMessage = {
2
+ msgId?: string
3
+ msgtype?: string
4
+ conversationType?: string
5
+ conversationId?: string
6
+ conversationTitle?: string
7
+ senderStaffId?: string
8
+ senderId?: string
9
+ senderNick?: string
10
+ sessionWebhook?: string
11
+ text?: { content?: string }
12
+ markdown?: { text?: string; title?: string }
13
+ content?: unknown
14
+ }
15
+
16
+ export type DingTalkAttachmentCandidate = {
17
+ kind: 'image' | 'file'
18
+ url?: string
19
+ downloadCode?: string
20
+ fileName?: string
21
+ }
22
+
23
+ export function parseDingTalkPayload(raw: unknown): DingTalkRobotMessage | null {
24
+ if (!raw) return null
25
+ if (typeof raw === 'object') return raw as DingTalkRobotMessage
26
+ if (typeof raw !== 'string') return null
27
+ try {
28
+ const parsed = JSON.parse(raw)
29
+ return parsed && typeof parsed === 'object' ? parsed as DingTalkRobotMessage : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+ export function isDingTalkDirectMessage(data: DingTalkRobotMessage): boolean {
35
+ return data.conversationType === '1'
36
+ }
37
+
38
+ export function getDingTalkSenderId(data: DingTalkRobotMessage): string | null {
39
+ const senderId = data.senderStaffId || data.senderId
40
+ return senderId ? String(senderId) : null
41
+ }
42
+
43
+ export function getDingTalkChatId(data: DingTalkRobotMessage): string | null {
44
+ const senderId = getDingTalkSenderId(data)
45
+ if (isDingTalkDirectMessage(data)) {
46
+ return senderId ? `dingtalk:dm:${senderId}` : null
47
+ }
48
+ return data.conversationId ? `dingtalk:group:${data.conversationId}` : null
49
+ }
50
+
51
+ export function extractDingTalkText(data: DingTalkRobotMessage): string {
52
+ if (typeof data.text?.content === 'string') return data.text.content.trim()
53
+ if (typeof data.markdown?.text === 'string') return data.markdown.text.trim()
54
+
55
+ const content = resolveContentObject(data.content)
56
+ if (typeof content?.text === 'string') return content.text.trim()
57
+ if (Array.isArray(content?.richText)) {
58
+ return content.richText
59
+ .map((item: unknown) => {
60
+ if (!item || typeof item !== 'object') return ''
61
+ const text = (item as { text?: unknown }).text
62
+ return typeof text === 'string' ? text : ''
63
+ })
64
+ .join('')
65
+ .trim()
66
+ }
67
+
68
+ return ''
69
+ }
70
+
71
+ export function extractDingTalkAttachments(data: DingTalkRobotMessage): DingTalkAttachmentCandidate[] {
72
+ const content = resolveContentObject(data.content)
73
+ const candidates: DingTalkAttachmentCandidate[] = []
74
+
75
+ if (data.msgtype === 'picture') {
76
+ const url = stringValue(content?.pictureUrl)
77
+ const downloadCode = stringValue(content?.downloadCode)
78
+ if (url || downloadCode) candidates.push({ kind: 'image', url, downloadCode })
79
+ } else if (data.msgtype === 'file') {
80
+ const downloadCode = stringValue(content?.downloadCode)
81
+ const fileName = stringValue(content?.fileName) || 'dingtalk-file'
82
+ if (downloadCode) candidates.push({ kind: 'file', downloadCode, fileName })
83
+ } else if (data.msgtype === 'richText') {
84
+ const richText = Array.isArray(content?.richText) ? content.richText : []
85
+ for (const item of richText) {
86
+ if (!item || typeof item !== 'object') continue
87
+ const record = item as Record<string, unknown>
88
+ const pictureUrl = stringValue(record.pictureUrl)
89
+ const downloadCode = stringValue(record.downloadCode)
90
+ if (pictureUrl || downloadCode) {
91
+ candidates.push({ kind: 'image', url: pictureUrl, downloadCode })
92
+ }
93
+ }
94
+ }
95
+
96
+ return candidates
97
+ }
98
+
99
+ function stringValue(value: unknown): string | undefined {
100
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
101
+ }
102
+
103
+ function resolveContentObject(raw: unknown): Record<string, any> | null {
104
+ if (!raw) return null
105
+ if (typeof raw === 'object') return raw as Record<string, any>
106
+ if (typeof raw !== 'string') return null
107
+ try {
108
+ const parsed = JSON.parse(raw)
109
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null
110
+ } catch {
111
+ return null
112
+ }
113
+ }
adapters/dingtalk/index.ts ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DingTalk Adapter for Claude Code Desktop.
3
+ *
4
+ * Uses DingTalk Stream to receive bot messages without a public webhook.
5
+ * The desktop Settings page stores clientId/clientSecret via QR registration.
6
+ */
7
+
8
+ import path from 'node:path'
9
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from 'dingtalk-stream'
10
+ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
11
+ import { MessageDedup } from '../common/message-dedup.js'
12
+ import { MessageBuffer } from '../common/message-buffer.js'
13
+ import { enqueue } from '../common/chat-queue.js'
14
+ import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
15
+ import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js'
16
+ import {
17
+ formatPermissionDecisionStatus,
18
+ formatPermissionInstructions,
19
+ parsePermissionCommand,
20
+ type PermissionDecision,
21
+ } from '../common/permission.js'
22
+ import { SessionStore } from '../common/session-store.js'
23
+ import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
24
+ import { isAllowedUser, tryPair } from '../common/pairing.js'
25
+ import { AttachmentStore } from '../common/attachment/attachment-store.js'
26
+ import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
27
+ import {
28
+ extractDingTalkAttachments,
29
+ extractDingTalkText,
30
+ getDingTalkChatId,
31
+ getDingTalkSenderId,
32
+ isDingTalkDirectMessage,
33
+ parseDingTalkPayload,
34
+ type DingTalkRobotMessage,
35
+ } from './helpers.js'
36
+ import { DingTalkMediaService } from './media.js'
37
+ import {
38
+ DingTalkAiCardService,
39
+ type DingTalkAiCardInstance,
40
+ type DingTalkAiCardTarget,
41
+ } from './ai-card.js'
42
+ import {
43
+ buildDingTalkPermissionCardParams,
44
+ DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
45
+ parseDingTalkPermissionCardAction,
46
+ } from './permission-card.js'
47
+ import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from './stream-state.js'
48
+
49
+ const DINGTALK_API = 'https://api.dingtalk.com'
50
+
51
+ const config = loadConfig()
52
+ if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
53
+ console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.')
54
+ process.exit(1)
55
+ }
56
+ const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk)
57
+
58
+ const bridge = new WsBridge(config.serverUrl, 'dingtalk')
59
+ const dedup = new MessageDedup()
60
+ const sessionStore = new SessionStore()
61
+ const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
62
+ const attachmentStore = new AttachmentStore()
63
+ const media = new DingTalkMediaService(attachmentStore)
64
+ const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
65
+ const sessionWebhooks = new Map<string, string>()
66
+ const pendingProjectSelection = new Map<string, boolean>()
67
+ const runtimeStates = new Map<string, ChatRuntimeState>()
68
+ const aiCardBuffers = new Map<string, MessageBuffer>()
69
+ const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
70
+ const streamingCards = new Map<string, Promise<DingTalkAiCardInstance | null>>()
71
+ const streamingCardText = new Map<string, string>()
72
+ const pendingPermissions = new Map<string, Set<string>>()
73
+ const pendingPermissionChats = new Map<string, string>()
74
+
75
+ let accessTokenCache: { token: string; expiresAt: number } | null = null
76
+
77
+ attachmentStore.gc().catch((err) => {
78
+ console.warn('[DingTalk] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
79
+ })
80
+
81
+ type ChatRuntimeState = {
82
+ state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
83
+ verb?: string
84
+ model?: string
85
+ pendingPermissionCount: number
86
+ }
87
+
88
+ function getRuntimeState(chatId: string): ChatRuntimeState {
89
+ let state = runtimeStates.get(chatId)
90
+ if (!state) {
91
+ state = { state: 'idle', pendingPermissionCount: 0 }
92
+ runtimeStates.set(chatId, state)
93
+ }
94
+ return state
95
+ }
96
+
97
+ async function getAccessToken(): Promise<string> {
98
+ const now = Date.now()
99
+ if (accessTokenCache && accessTokenCache.expiresAt > now + 60_000) {
100
+ return accessTokenCache.token
101
+ }
102
+
103
+ const res = await fetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({
107
+ appKey: config.dingtalk.clientId,
108
+ appSecret: config.dingtalk.clientSecret,
109
+ }),
110
+ })
111
+ const data = await res.json().catch(() => null) as { accessToken?: string; expireIn?: number; message?: string } | null
112
+ if (!res.ok || !data?.accessToken) {
113
+ throw new Error(data?.message || `accessToken request failed: ${res.status}`)
114
+ }
115
+
116
+ accessTokenCache = {
117
+ token: data.accessToken,
118
+ expiresAt: now + Number(data.expireIn ?? 7200) * 1000,
119
+ }
120
+ return data.accessToken
121
+ }
122
+
123
+ async function sendText(chatId: string, text: string): Promise<void> {
124
+ const sessionWebhook = sessionWebhooks.get(chatId)
125
+ if (!sessionWebhook) {
126
+ console.warn(`[DingTalk] Missing sessionWebhook for ${chatId}; cannot send response`)
127
+ return
128
+ }
129
+
130
+ const token = await getAccessToken()
131
+ for (const chunk of splitMessage(text, 3500)) {
132
+ const res = await fetch(sessionWebhook, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'x-acs-dingtalk-access-token': token,
137
+ },
138
+ body: JSON.stringify({
139
+ msgtype: 'markdown',
140
+ markdown: {
141
+ title: 'Claude Code',
142
+ text: chunk,
143
+ },
144
+ }),
145
+ })
146
+ if (!res.ok) {
147
+ const body = await res.text().catch(() => '')
148
+ console.warn(`[DingTalk] sendText failed: ${res.status} ${body}`)
149
+ }
150
+ }
151
+ }
152
+
153
+ function getAiCardBuffer(chatId: string): MessageBuffer {
154
+ let buffer = aiCardBuffers.get(chatId)
155
+ if (!buffer) {
156
+ buffer = new MessageBuffer(
157
+ async (text, isComplete) => flushToAiCard(chatId, text, isComplete),
158
+ 1200,
159
+ 200,
160
+ )
161
+ aiCardBuffers.set(chatId, buffer)
162
+ }
163
+ return buffer
164
+ }
165
+
166
+ function getOrCreateAiCard(chatId: string): Promise<DingTalkAiCardInstance | null> | null {
167
+ const target = aiCardTargets.get(chatId)
168
+ if (!target) return null
169
+
170
+ let card = streamingCards.get(chatId)
171
+ if (!card) {
172
+ card = aiCards.createForTarget(target)
173
+ streamingCards.set(chatId, card)
174
+ }
175
+ return card
176
+ }
177
+
178
+ async function flushToAiCard(chatId: string, newText: string, isComplete: boolean): Promise<void> {
179
+ const fullText = (streamingCardText.get(chatId) ?? '') + newText
180
+ streamingCardText.set(chatId, fullText)
181
+ if (!fullText.trim()) return
182
+
183
+ const cardPromise = getOrCreateAiCard(chatId)
184
+ const card = cardPromise ? await cardPromise : null
185
+ if (!card) {
186
+ if (isComplete) await sendText(chatId, fullText)
187
+ return
188
+ }
189
+
190
+ try {
191
+ if (isComplete) {
192
+ await aiCards.finish(card, fullText)
193
+ streamingCards.delete(chatId)
194
+ streamingCardText.delete(chatId)
195
+ aiCardBuffers.get(chatId)?.reset()
196
+ aiCardBuffers.delete(chatId)
197
+ } else {
198
+ await aiCards.stream(card, `${fullText} ▍`, false)
199
+ }
200
+ } catch (err) {
201
+ console.warn('[DingTalk][AICard] stream failed, falling back to markdown:', err instanceof Error ? err.message : err)
202
+ streamingCards.delete(chatId)
203
+ if (isComplete) await sendText(chatId, fullText)
204
+ }
205
+ }
206
+
207
+ function clearTransientChatState(chatId: string): void {
208
+ resetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText }, chatId)
209
+ clearPendingPermissions(chatId)
210
+ const runtime = getRuntimeState(chatId)
211
+ runtime.state = 'idle'
212
+ runtime.verb = undefined
213
+ runtime.pendingPermissionCount = 0
214
+ }
215
+
216
+ function clearPendingPermissions(chatId: string): void {
217
+ const pending = pendingPermissions.get(chatId)
218
+ if (pending) {
219
+ for (const requestId of pending) pendingPermissionChats.delete(requestId)
220
+ }
221
+ pendingPermissions.delete(chatId)
222
+ }
223
+
224
+ async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
225
+ const stored = sessionStore.get(chatId)
226
+ if (!stored) return null
227
+
228
+ if (!bridge.hasSession(chatId)) {
229
+ bridge.connectSession(chatId, stored.sessionId)
230
+ bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
231
+ const opened = await bridge.waitForOpen(chatId)
232
+ if (!opened) return null
233
+ }
234
+
235
+ return stored
236
+ }
237
+
238
+ async function buildStatusText(chatId: string): Promise<string> {
239
+ const stored = await ensureExistingSession(chatId)
240
+ if (!stored) return formatImStatus(null)
241
+
242
+ const runtime = getRuntimeState(chatId)
243
+ let projectName = path.basename(stored.workDir) || stored.workDir
244
+ let branch: string | null = null
245
+
246
+ try {
247
+ const gitInfo = await httpClient.getGitInfo(stored.sessionId)
248
+ projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
249
+ branch = gitInfo.branch
250
+ } catch {
251
+ // Status should still be useful when git lookup fails.
252
+ }
253
+
254
+ let taskCounts:
255
+ | {
256
+ total: number
257
+ pending: number
258
+ inProgress: number
259
+ completed: number
260
+ }
261
+ | undefined
262
+
263
+ try {
264
+ const tasks = await httpClient.getTasksForSession(stored.sessionId)
265
+ if (tasks.length > 0) {
266
+ taskCounts = {
267
+ total: tasks.length,
268
+ pending: tasks.filter((task) => task.status === 'pending').length,
269
+ inProgress: tasks.filter((task) => task.status === 'in_progress').length,
270
+ completed: tasks.filter((task) => task.status === 'completed').length,
271
+ }
272
+ }
273
+ } catch {
274
+ // Ignore task lookup failures.
275
+ }
276
+
277
+ return formatImStatus({
278
+ sessionId: stored.sessionId,
279
+ projectName,
280
+ branch,
281
+ model: runtime.model,
282
+ state: runtime.state,
283
+ verb: runtime.verb,
284
+ pendingPermissionCount: runtime.pendingPermissionCount,
285
+ taskCounts,
286
+ })
287
+ }
288
+
289
+ async function ensureSession(chatId: string): Promise<boolean> {
290
+ if (bridge.hasSession(chatId)) return true
291
+
292
+ const stored = sessionStore.get(chatId)
293
+ if (stored) {
294
+ bridge.connectSession(chatId, stored.sessionId)
295
+ bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
296
+ return await bridge.waitForOpen(chatId)
297
+ }
298
+
299
+ return await createSessionForChat(chatId, defaultWorkDir)
300
+ }
301
+
302
+ async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
303
+ try {
304
+ bridge.resetSession(chatId)
305
+ clearTransientChatState(chatId)
306
+
307
+ const sessionId = await httpClient.createSession(workDir)
308
+ sessionStore.set(chatId, sessionId, workDir)
309
+ bridge.connectSession(chatId, sessionId)
310
+ bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
311
+ const opened = await bridge.waitForOpen(chatId)
312
+ if (!opened) {
313
+ await sendText(chatId, '⚠️ 连接服务器超时,请重试。')
314
+ return false
315
+ }
316
+ return true
317
+ } catch (err) {
318
+ await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
319
+ return false
320
+ }
321
+ }
322
+
323
+ function formatProjectList(projects: RecentProject[]): string {
324
+ const lines = projects.slice(0, 10).map((project, index) => {
325
+ const branch = project.branch ? ` (${project.branch})` : ''
326
+ return `${index + 1}. **${project.projectName}**${branch}\n ${project.realPath}`
327
+ })
328
+ return `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n也可以发送 /new <编号或名称>`
329
+ }
330
+
331
+ async function showProjectPicker(chatId: string): Promise<void> {
332
+ try {
333
+ const projects = await httpClient.listRecentProjects()
334
+ if (projects.length === 0) {
335
+ await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
336
+ return
337
+ }
338
+ pendingProjectSelection.set(chatId, true)
339
+ await sendText(chatId, formatProjectList(projects))
340
+ } catch (err) {
341
+ await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
342
+ }
343
+ }
344
+
345
+ async function startNewSession(chatId: string, query?: string): Promise<void> {
346
+ bridge.resetSession(chatId)
347
+ sessionStore.delete(chatId)
348
+ clearTransientChatState(chatId)
349
+ pendingProjectSelection.delete(chatId)
350
+ runtimeStates.delete(chatId)
351
+
352
+ if (query) {
353
+ try {
354
+ const { project, ambiguous } = await httpClient.matchProject(query)
355
+ if (project) {
356
+ const ok = await createSessionForChat(chatId, project.realPath)
357
+ if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
358
+ return
359
+ }
360
+ if (ambiguous) {
361
+ const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n')
362
+ await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
363
+ return
364
+ }
365
+ await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
366
+ } catch (err) {
367
+ await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
368
+ }
369
+ return
370
+ }
371
+
372
+ const ok = await createSessionForChat(chatId, defaultWorkDir)
373
+ if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
374
+ }
375
+
376
+ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
377
+ const runtime = getRuntimeState(chatId)
378
+
379
+ switch (msg.type) {
380
+ case 'connected':
381
+ break
382
+ case 'status':
383
+ runtime.state = msg.state
384
+ runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
385
+ break
386
+ case 'content_start':
387
+ if (msg.blockType === 'text') {
388
+ runtime.state = 'streaming'
389
+ }
390
+ if (msg.blockType === 'tool_use') runtime.state = 'tool_executing'
391
+ break
392
+ case 'content_delta':
393
+ if (typeof msg.text === 'string' && msg.text) getAiCardBuffer(chatId).append(msg.text)
394
+ break
395
+ case 'tool_use_complete':
396
+ runtime.state = 'streaming'
397
+ break
398
+ case 'permission_request': {
399
+ await sendPermissionRequest(chatId, msg)
400
+ break
401
+ }
402
+ case 'message_complete':
403
+ runtime.state = 'idle'
404
+ runtime.verb = undefined
405
+ await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
406
+ break
407
+ case 'error':
408
+ runtime.state = 'idle'
409
+ runtime.verb = undefined
410
+ aiCardBuffers.get(chatId)?.reset()
411
+ streamingCards.delete(chatId)
412
+ streamingCardText.delete(chatId)
413
+ await sendText(chatId, `❌ ${msg.message}`)
414
+ break
415
+ case 'system_notification':
416
+ if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
417
+ const model = (msg.data as Record<string, unknown>).model
418
+ if (typeof model === 'string' && model.trim()) runtime.model = model
419
+ }
420
+ break
421
+ }
422
+ }
423
+
424
+ async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promise<void> {
425
+ const runtime = getRuntimeState(chatId)
426
+ runtime.pendingPermissionCount += 1
427
+ runtime.state = 'permission_pending'
428
+ await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
429
+
430
+ const set = pendingPermissions.get(chatId) ?? new Set<string>()
431
+ set.add(msg.requestId)
432
+ pendingPermissions.set(chatId, set)
433
+ pendingPermissionChats.set(msg.requestId, chatId)
434
+
435
+ const requestText = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
436
+ const instructions = formatPermissionInstructions(msg.requestId)
437
+ const templateId = config.dingtalk.permissionCardTemplateId.trim()
438
+ const target = aiCardTargets.get(chatId)
439
+
440
+ if (templateId && target) {
441
+ const card = await aiCards.createForTarget(target, {
442
+ cardTemplateId: templateId,
443
+ outTrackId: `permission_${msg.requestId}`,
444
+ callbackRouteKey: DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
445
+ cardParamMap: buildDingTalkPermissionCardParams(msg.toolName, msg.input, msg.requestId),
446
+ })
447
+ if (card) {
448
+ await sendText(chatId, `${requestText}\n\n已发送钉钉权限卡片;如果卡片不可见,也可以${instructions}`)
449
+ return
450
+ }
451
+ }
452
+
453
+ await sendText(chatId, `${requestText}\n\n${instructions}`)
454
+ }
455
+
456
+ function handlePermissionCommand(chatId: string, text: string): boolean {
457
+ const decision = parsePermissionCommand(text, pendingPermissions.get(chatId))
458
+ if (!decision) return false
459
+
460
+ const sent = applyPermissionDecision(chatId, decision)
461
+ if (!sent) return true
462
+
463
+ void sendText(chatId, formatPermissionDecisionStatus(decision))
464
+ return true
465
+ }
466
+
467
+ function applyPermissionDecision(chatId: string, decision: PermissionDecision): boolean {
468
+ const { requestId, allowed, rule } = decision
469
+ const pending = pendingPermissions.get(chatId)
470
+ if (!pending?.has(requestId)) {
471
+ void sendText(chatId, `未找到待确认的权限请求:${requestId}`)
472
+ return false
473
+ }
474
+
475
+ const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule)
476
+ if (!sent) {
477
+ void sendText(chatId, '权限响应发送失败,请检查会话状态。')
478
+ return false
479
+ }
480
+
481
+ pending.delete(requestId)
482
+ pendingPermissionChats.delete(requestId)
483
+ const runtime = getRuntimeState(chatId)
484
+ runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
485
+ return sent
486
+ }
487
+
488
+ async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise<void> {
489
+ enqueue(chatId, async () => {
490
+ const trimmed = text.trim()
491
+ const hasAttachments = attachments.length > 0
492
+
493
+ if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
494
+
495
+ if (!hasAttachments && pendingProjectSelection.has(chatId)) {
496
+ if (trimmed) await startNewSession(chatId, trimmed)
497
+ return
498
+ }
499
+
500
+ if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) {
501
+ const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
502
+ await startNewSession(chatId, arg || undefined)
503
+ return
504
+ }
505
+ if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
506
+ await sendText(chatId, formatImHelp())
507
+ return
508
+ }
509
+ if (!hasAttachments && (trimmed === '/status' || trimmed === '状态')) {
510
+ await sendText(chatId, await buildStatusText(chatId))
511
+ return
512
+ }
513
+ if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) {
514
+ const stored = await ensureExistingSession(chatId)
515
+ if (!stored) {
516
+ await sendText(chatId, formatImStatus(null))
517
+ return
518
+ }
519
+ clearTransientChatState(chatId)
520
+ if (!bridge.sendUserMessage(chatId, '/clear')) {
521
+ await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
522
+ return
523
+ }
524
+ await sendText(chatId, '🧹 已清空当前会话上下文。')
525
+ return
526
+ }
527
+ if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) {
528
+ const stored = await ensureExistingSession(chatId)
529
+ if (!stored) {
530
+ await sendText(chatId, formatImStatus(null))
531
+ return
532
+ }
533
+ bridge.sendStopGeneration(chatId)
534
+ await sendText(chatId, '⏹ 已发送停止信号。')
535
+ return
536
+ }
537
+ if (!hasAttachments && (trimmed === '/projects' || trimmed === '项目列表')) {
538
+ await showProjectPicker(chatId)
539
+ return
540
+ }
541
+
542
+ const ready = await ensureSession(chatId)
543
+ if (!ready) return
544
+ const effectiveText = trimmed || (attachments.length > 0 ? '(用户发送了附件)' : '')
545
+ if (!effectiveText && attachments.length === 0) return
546
+ if (!bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)) {
547
+ await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
548
+ }
549
+ })
550
+ }
551
+
552
+ async function handleRobotMessage(data: DingTalkRobotMessage): Promise<void> {
553
+ if (!isDingTalkDirectMessage(data)) return
554
+
555
+ const chatId = getDingTalkChatId(data)
556
+ const userId = getDingTalkSenderId(data)
557
+ const text = extractDingTalkText(data)
558
+ const mediaCandidates = extractDingTalkAttachments(data)
559
+ if (!chatId || !userId || (!text && mediaCandidates.length === 0)) return
560
+
561
+ if (data.sessionWebhook) sessionWebhooks.set(chatId, data.sessionWebhook)
562
+
563
+ if (!isAllowedUser('dingtalk', userId)) {
564
+ const success = tryPair(text, { userId, displayName: data.senderNick || 'DingTalk User' }, 'dingtalk')
565
+ await sendText(
566
+ chatId,
567
+ success
568
+ ? '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。发送 /help 查看可用命令。'
569
+ : '🔒 未授权。请先在 Claude Code 桌面端完成钉钉扫码绑定,再生成 IM 配对码后发送给我。',
570
+ )
571
+ return
572
+ }
573
+
574
+ aiCardTargets.set(chatId, { type: 'user', userId })
575
+ const attachments = await collectAttachments(chatId, mediaCandidates)
576
+ await routeUserMessage(chatId, text, attachments)
577
+ }
578
+
579
+ async function handleCardCallback(raw: unknown): Promise<void> {
580
+ const action = parseDingTalkPermissionCardAction(raw)
581
+ if (!action) return
582
+
583
+ const chatId = action.chatId && pendingPermissions.has(action.chatId)
584
+ ? action.chatId
585
+ : pendingPermissionChats.get(action.requestId)
586
+ if (!chatId) {
587
+ console.warn(`[DingTalk][Card] permission request not found: ${action.requestId}`)
588
+ return
589
+ }
590
+
591
+ if (applyPermissionDecision(chatId, action)) {
592
+ await sendText(chatId, formatPermissionDecisionStatus(action))
593
+ }
594
+ }
595
+
596
+ async function collectAttachments(
597
+ chatId: string,
598
+ candidates: ReturnType<typeof extractDingTalkAttachments>,
599
+ ): Promise<AttachmentRef[]> {
600
+ if (candidates.length === 0) return []
601
+ const stored = sessionStore.get(chatId)
602
+ const sessionId = stored?.sessionId ?? chatId
603
+ let token: string
604
+ try {
605
+ token = await getAccessToken()
606
+ } catch (err) {
607
+ console.error('[DingTalk] access token for attachment download failed:', err)
608
+ await sendText(chatId, '📎 附件下载授权失败,请稍后重试。')
609
+ return []
610
+ }
611
+
612
+ const settled = await Promise.allSettled(
613
+ candidates.map((candidate) =>
614
+ media.downloadCandidate(candidate, sessionId, {
615
+ clientId: config.dingtalk.clientId,
616
+ accessToken: token,
617
+ }),
618
+ ),
619
+ )
620
+ const attachments: AttachmentRef[] = []
621
+ let failures = 0
622
+ for (const result of settled) {
623
+ if (result.status === 'rejected') {
624
+ failures += 1
625
+ console.error('[DingTalk] media download failed:', result.reason)
626
+ continue
627
+ }
628
+ const local = result.value
629
+ const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
630
+ if (!check.ok) {
631
+ await sendText(chatId, check.hint)
632
+ continue
633
+ }
634
+ if (local.kind === 'image') {
635
+ attachments.push({
636
+ type: 'image',
637
+ name: local.name,
638
+ data: local.buffer.toString('base64'),
639
+ mimeType: local.mimeType,
640
+ })
641
+ } else {
642
+ attachments.push({
643
+ type: 'file',
644
+ name: local.name,
645
+ path: local.path,
646
+ mimeType: local.mimeType,
647
+ })
648
+ }
649
+ }
650
+ if (failures > 0) {
651
+ await sendText(
652
+ chatId,
653
+ failures === candidates.length ? '📎 附件下载失败,请稍后重试。' : `📎 ${failures} 个附件下载失败,已跳过。`,
654
+ )
655
+ }
656
+ return attachments
657
+ }
658
+
659
+ async function start(): Promise<void> {
660
+ const client = new DWClient({
661
+ clientId: config.dingtalk.clientId,
662
+ clientSecret: config.dingtalk.clientSecret,
663
+ endpoint: config.dingtalk.endpoint,
664
+ autoReconnect: true,
665
+ keepAlive: true,
666
+ } as any)
667
+
668
+ client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
669
+ const messageId = res.headers?.messageId
670
+ if (messageId) {
671
+ client.socketCallBackResponse(messageId, { success: true })
672
+ if (!dedup.tryRecord(`header:${messageId}`)) return
673
+ }
674
+
675
+ const data = parseDingTalkPayload(res.data)
676
+ if (!data) return
677
+ if (data.msgId && !dedup.tryRecord(`body:${data.msgId}`)) return
678
+
679
+ await handleRobotMessage(data)
680
+ })
681
+
682
+ client.registerCallbackListener(TOPIC_CARD, async (res: any) => {
683
+ const messageId = res.headers?.messageId
684
+ if (messageId) {
685
+ client.socketCallBackResponse(messageId, { success: true })
686
+ if (!dedup.tryRecord(`card:${messageId}`)) return
687
+ }
688
+
689
+ await handleCardCallback(res.data ?? res)
690
+ })
691
+
692
+ await client.connect()
693
+ console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`)
694
+
695
+ const shutdown = async () => {
696
+ console.log('[DingTalk] Shutting down...')
697
+ bridge.destroy()
698
+ dedup.destroy()
699
+ try {
700
+ await client.disconnect()
701
+ } catch {
702
+ // ignore
703
+ }
704
+ process.exit(0)
705
+ }
706
+ process.once('SIGINT', () => void shutdown())
707
+ process.once('SIGTERM', () => void shutdown())
708
+ }
709
+
710
+ start().catch((err) => {
711
+ console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err)
712
+ process.exit(1)
713
+ })
adapters/dingtalk/media.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path'
2
+ import { AttachmentStore } from '../common/attachment/attachment-store.js'
3
+ import type { LocalAttachment } from '../common/attachment/attachment-types.js'
4
+ import type { DingTalkAttachmentCandidate } from './helpers.js'
5
+
6
+ const DINGTALK_API = 'https://api.dingtalk.com'
7
+
8
+ export class DingTalkMediaService {
9
+ constructor(private readonly store: AttachmentStore) {}
10
+
11
+ async downloadCandidate(
12
+ candidate: DingTalkAttachmentCandidate,
13
+ sessionId: string,
14
+ opts: { clientId: string; accessToken: string },
15
+ ): Promise<LocalAttachment> {
16
+ const downloadUrl = candidate.url || await this.resolveDownloadUrl(candidate.downloadCode, opts)
17
+ if (!downloadUrl) throw new Error('DingTalk media item is missing a download URL')
18
+
19
+ const resp = await fetch(downloadUrl)
20
+ if (!resp.ok) {
21
+ throw new Error(`DingTalk media download failed: ${resp.status} ${resp.statusText}`)
22
+ }
23
+ const buffer = Buffer.from(await resp.arrayBuffer())
24
+ const contentType = resp.headers.get('content-type') || inferMime(candidate.fileName, candidate.kind)
25
+ const name = candidate.fileName || buildImageName(contentType)
26
+ const target = this.store.resolvePath('dingtalk', sessionId, name)
27
+ const savedPath = await this.store.write(target, buffer)
28
+
29
+ return {
30
+ kind: candidate.kind,
31
+ name,
32
+ path: savedPath,
33
+ buffer,
34
+ size: buffer.length,
35
+ mimeType: contentType,
36
+ }
37
+ }
38
+
39
+ private async resolveDownloadUrl(
40
+ downloadCode: string | undefined,
41
+ opts: { clientId: string; accessToken: string },
42
+ ): Promise<string | null> {
43
+ if (!downloadCode) return null
44
+ const resp = await fetch(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'x-acs-dingtalk-access-token': opts.accessToken,
49
+ },
50
+ body: JSON.stringify({
51
+ downloadCode,
52
+ robotCode: opts.clientId,
53
+ }),
54
+ })
55
+ const body = await resp.json().catch(() => null) as { downloadUrl?: string; message?: string } | null
56
+ if (!resp.ok || !body?.downloadUrl) {
57
+ throw new Error(body?.message || `DingTalk downloadCode exchange failed: ${resp.status}`)
58
+ }
59
+ return body.downloadUrl
60
+ }
61
+ }
62
+
63
+ function buildImageName(mime?: string): string {
64
+ const ext = mime?.includes('png')
65
+ ? '.png'
66
+ : mime?.includes('gif')
67
+ ? '.gif'
68
+ : mime?.includes('webp')
69
+ ? '.webp'
70
+ : '.jpg'
71
+ return `dingtalk-image-${Date.now()}${ext}`
72
+ }
73
+
74
+ function inferMime(fileName: string | undefined, kind: 'image' | 'file'): string {
75
+ if (kind === 'image') return 'image/jpeg'
76
+ const ext = path.extname(fileName || '').toLowerCase()
77
+ if (ext === '.pdf') return 'application/pdf'
78
+ if (ext === '.txt') return 'text/plain'
79
+ if (ext === '.png') return 'image/png'
80
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
81
+ if (ext === '.gif') return 'image/gif'
82
+ if (ext === '.webp') return 'image/webp'
83
+ return 'application/octet-stream'
84
+ }
adapters/dingtalk/permission-card.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { truncateInput } from '../common/format.js'
2
+ import { parsePermitCallbackData, type PermissionDecision } from '../common/permission.js'
3
+
4
+ export const DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE = 'permission'
5
+
6
+ export type DingTalkPermissionCardAction = PermissionDecision & {
7
+ outTrackId?: string
8
+ chatId?: string
9
+ }
10
+
11
+ export function buildDingTalkPermissionCardParams(
12
+ toolName: string,
13
+ input: unknown,
14
+ requestId: string,
15
+ ): Record<string, unknown> {
16
+ const allowValue = { action: 'permit', requestId, allowed: true }
17
+ const alwaysValue = { action: 'permit', requestId, allowed: true, rule: 'always' }
18
+ const denyValue = { action: 'permit', requestId, allowed: false }
19
+
20
+ return {
21
+ title: 'Claude Code 需要权限确认',
22
+ toolName,
23
+ requestId,
24
+ inputPreview: truncateInput(input, 600),
25
+ allowText: '允许一次',
26
+ alwaysText: '永久允许',
27
+ denyText: '拒绝',
28
+ allowValue: JSON.stringify(allowValue),
29
+ alwaysValue: JSON.stringify(alwaysValue),
30
+ denyValue: JSON.stringify(denyValue),
31
+ permissionActions: JSON.stringify([
32
+ { text: '允许一次', value: allowValue },
33
+ { text: '永久允许', value: alwaysValue },
34
+ { text: '拒绝', value: denyValue },
35
+ ]),
36
+ sys_full_json_obj: JSON.stringify({
37
+ order: ['title', 'toolName', 'inputPreview'],
38
+ actions: ['allowValue', 'alwaysValue', 'denyValue'],
39
+ }),
40
+ config: JSON.stringify({ autoLayout: true }),
41
+ }
42
+ }
43
+
44
+ export function parseDingTalkPermissionCardAction(raw: unknown): DingTalkPermissionCardAction | null {
45
+ const root = parseMaybeJson(raw)
46
+ const values = collectValues(root)
47
+
48
+ for (const value of values) {
49
+ if (typeof value === 'string') {
50
+ const direct = parsePermitCallbackData(value)
51
+ if (direct) return direct
52
+ const parsed = parseMaybeJson(value)
53
+ if (parsed !== value) {
54
+ const nested = parseDingTalkPermissionCardAction(parsed)
55
+ if (nested) return nested
56
+ }
57
+ }
58
+ }
59
+
60
+ const objects = values.filter(isRecord)
61
+ for (const obj of objects) {
62
+ const requestId = readString(obj, ['requestId', 'request_id', 'permissionRequestId'])
63
+ if (!requestId) continue
64
+
65
+ const action = readString(obj, ['action', 'actionType', 'decision', 'value', 'actionValue', 'command'])?.toLowerCase()
66
+ const allowed = readBoolean(obj, ['allowed', 'allow', 'approved'])
67
+ const rule = readString(obj, ['rule']) === 'always' ? 'always' : undefined
68
+ const outTrackId = readString(obj, ['outTrackId', 'cardInstanceId'])
69
+ const chatId = readString(obj, ['chatId', 'conversationId', 'openConversationId'])
70
+
71
+ if (allowed !== undefined) return { requestId, allowed, rule, outTrackId, chatId }
72
+ if (action && ['allow', 'yes', 'approve', 'approved', 'permit'].includes(action)) {
73
+ return { requestId, allowed: true, rule, outTrackId, chatId }
74
+ }
75
+ if (action && ['always', 'allow-always', 'approve-always'].includes(action)) {
76
+ return { requestId, allowed: true, rule: 'always', outTrackId, chatId }
77
+ }
78
+ if (action && ['deny', 'no', 'reject', 'rejected'].includes(action)) {
79
+ return { requestId, allowed: false, outTrackId, chatId }
80
+ }
81
+ }
82
+
83
+ return null
84
+ }
85
+
86
+ function parseMaybeJson(value: unknown): unknown {
87
+ if (typeof value !== 'string') return value
88
+ const trimmed = value.trim()
89
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value
90
+ try {
91
+ return JSON.parse(trimmed)
92
+ } catch {
93
+ return value
94
+ }
95
+ }
96
+
97
+ function collectValues(value: unknown, seen = new Set<unknown>()): unknown[] {
98
+ const parsed = parseMaybeJson(value)
99
+ if (parsed && typeof parsed === 'object') {
100
+ if (seen.has(parsed)) return []
101
+ seen.add(parsed)
102
+ }
103
+
104
+ const values = [parsed]
105
+ if (Array.isArray(parsed)) {
106
+ for (const item of parsed) values.push(...collectValues(item, seen))
107
+ } else if (isRecord(parsed)) {
108
+ for (const item of Object.values(parsed)) values.push(...collectValues(item, seen))
109
+ }
110
+ return values
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return !!value && typeof value === 'object' && !Array.isArray(value)
115
+ }
116
+
117
+ function readString(obj: Record<string, unknown>, keys: string[]): string | undefined {
118
+ for (const key of keys) {
119
+ const value = obj[key]
120
+ if (typeof value === 'string' && value.trim()) return value.trim()
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ function readBoolean(obj: Record<string, unknown>, keys: string[]): boolean | undefined {
126
+ for (const key of keys) {
127
+ const value = obj[key]
128
+ if (typeof value === 'boolean') return value
129
+ if (typeof value === 'string') {
130
+ if (/^(true|yes|allow|approve|permit)$/i.test(value)) return true
131
+ if (/^(false|no|deny|reject)$/i.test(value)) return false
132
+ }
133
+ }
134
+ return undefined
135
+ }
adapters/dingtalk/stream-state.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MessageBuffer } from '../common/message-buffer.js'
2
+ import type { DingTalkAiCardInstance } from './ai-card.js'
3
+
4
+ export type DingTalkStreamingState = {
5
+ aiCardBuffers: Map<string, MessageBuffer>
6
+ streamingCards: Map<string, Promise<DingTalkAiCardInstance | null>>
7
+ streamingCardText: Map<string, string>
8
+ finalize?: () => Promise<void>
9
+ }
10
+
11
+ export function resetDingTalkStreamingState(
12
+ state: DingTalkStreamingState,
13
+ chatId: string,
14
+ ): void {
15
+ state.aiCardBuffers.get(chatId)?.reset()
16
+ state.aiCardBuffers.delete(chatId)
17
+ state.streamingCards.delete(chatId)
18
+ state.streamingCardText.delete(chatId)
19
+ }
20
+
21
+ export async function finishAndResetDingTalkStreamingState(
22
+ state: DingTalkStreamingState,
23
+ chatId: string,
24
+ ): Promise<void> {
25
+ await state.aiCardBuffers.get(chatId)?.complete()
26
+ if (state.finalize && (state.streamingCards.has(chatId) || state.streamingCardText.has(chatId))) {
27
+ await state.finalize()
28
+ }
29
+ resetDingTalkStreamingState(state, chatId)
30
+ }
adapters/feishu/__tests__/card-errors.test.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * card-errors 单元测试
3
+ */
4
+
5
+ import { describe, it, expect } from 'bun:test'
6
+ import {
7
+ CARD_ERROR,
8
+ CARD_CONTENT_SUB_ERROR,
9
+ extractLarkApiCode,
10
+ extractSubCode,
11
+ parseCardApiError,
12
+ isCardRateLimitError,
13
+ isCardTableLimitError,
14
+ } from '../card-errors.js'
15
+
16
+ describe('extractLarkApiCode', () => {
17
+ it('从 err.code 直接提取', () => {
18
+ expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
19
+ })
20
+
21
+ it('从 err.data.code 提取', () => {
22
+ expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
23
+ })
24
+
25
+ it('从 err.response.data.code 提取(Axios 风格)', () => {
26
+ expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
27
+ })
28
+
29
+ it('数字字符串被强制转成 number', () => {
30
+ expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
31
+ })
32
+
33
+ it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
34
+ const err = {
35
+ code: 1,
36
+ data: { code: 2 },
37
+ response: { data: { code: 3 } },
38
+ }
39
+ expect(extractLarkApiCode(err)).toBe(1)
40
+ })
41
+
42
+ it('none → undefined', () => {
43
+ expect(extractLarkApiCode({})).toBeUndefined()
44
+ expect(extractLarkApiCode(null)).toBeUndefined()
45
+ expect(extractLarkApiCode(undefined)).toBeUndefined()
46
+ expect(extractLarkApiCode('just a string')).toBeUndefined()
47
+ expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
48
+ })
49
+
50
+ it('非有限数字被忽略', () => {
51
+ expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
52
+ expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe('extractSubCode', () => {
57
+ it('识别标准的 ErrCode: 11310', () => {
58
+ const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
59
+ expect(extractSubCode(msg)).toBe(11310)
60
+ })
61
+
62
+ it('无 ErrCode 时返回 null', () => {
63
+ expect(extractSubCode('random error message')).toBeNull()
64
+ expect(extractSubCode('')).toBeNull()
65
+ })
66
+
67
+ it('ErrCode 大小写容错(冒号后多空格)', () => {
68
+ expect(extractSubCode('ErrCode: 42')).toBe(42)
69
+ })
70
+ })
71
+
72
+ describe('parseCardApiError', () => {
73
+ it('从 SDK 风格错误提取完整结构', () => {
74
+ const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
75
+ const parsed = parseCardApiError(err)
76
+ expect(parsed).toEqual({
77
+ code: 230099,
78
+ subCode: 11310,
79
+ errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
80
+ })
81
+ })
82
+
83
+ it('从 Axios 风格错误(response.data.msg)提取', () => {
84
+ const err = {
85
+ response: {
86
+ data: {
87
+ code: 230020,
88
+ msg: 'rate limited',
89
+ },
90
+ },
91
+ }
92
+ const parsed = parseCardApiError(err)
93
+ expect(parsed?.code).toBe(230020)
94
+ expect(parsed?.errMsg).toBe('rate limited')
95
+ expect(parsed?.subCode).toBeNull()
96
+ })
97
+
98
+ it('无 code 时返回 null', () => {
99
+ expect(parseCardApiError({})).toBeNull()
100
+ expect(parseCardApiError(null)).toBeNull()
101
+ expect(parseCardApiError('string')).toBeNull()
102
+ })
103
+
104
+ it('有 code 无 msg 时 errMsg 为空字符串', () => {
105
+ const parsed = parseCardApiError({ code: 230020 })
106
+ expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
107
+ })
108
+
109
+ it('fallback 到 err.message', () => {
110
+ const err = Object.assign(new Error('fallback text'), { code: 230099 })
111
+ const parsed = parseCardApiError(err)
112
+ expect(parsed?.errMsg).toBe('fallback text')
113
+ })
114
+ })
115
+
116
+ describe('isCardRateLimitError', () => {
117
+ it('识别 230020', () => {
118
+ expect(isCardRateLimitError({ code: 230020 })).toBe(true)
119
+ })
120
+
121
+ it('识别 Axios 风格 230020', () => {
122
+ expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
123
+ })
124
+
125
+ it('不匹配其他 code', () => {
126
+ expect(isCardRateLimitError({ code: 230099 })).toBe(false)
127
+ expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
128
+ })
129
+
130
+ it('非错误对象返回 false', () => {
131
+ expect(isCardRateLimitError(null)).toBe(false)
132
+ expect(isCardRateLimitError({})).toBe(false)
133
+ expect(isCardRateLimitError(new Error('random'))).toBe(false)
134
+ })
135
+ })
136
+
137
+ describe('isCardTableLimitError', () => {
138
+ const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
139
+
140
+ it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
141
+ const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
142
+ expect(isCardTableLimitError(err)).toBe(true)
143
+ })
144
+
145
+ it('从 Axios 风格的 response.data 匹配', () => {
146
+ const err = {
147
+ response: {
148
+ data: {
149
+ code: 230099,
150
+ msg: validMsg,
151
+ },
152
+ },
153
+ }
154
+ expect(isCardTableLimitError(err)).toBe(true)
155
+ })
156
+
157
+ it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
158
+ const err = {
159
+ code: CARD_ERROR.CARD_CONTENT_FAILED,
160
+ msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
161
+ }
162
+ expect(isCardTableLimitError(err)).toBe(false)
163
+ })
164
+
165
+ it('code 不是 230099 → false', () => {
166
+ const err = { code: 230020, msg: validMsg }
167
+ expect(isCardTableLimitError(err)).toBe(false)
168
+ })
169
+
170
+ it('没有 subCode → false', () => {
171
+ const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
172
+ expect(isCardTableLimitError(err)).toBe(false)
173
+ })
174
+
175
+ it('不区分 "table number" 的大小写', () => {
176
+ const err = {
177
+ code: 230099,
178
+ msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
179
+ }
180
+ expect(isCardTableLimitError(err)).toBe(true)
181
+ })
182
+ })
183
+
184
+ describe('常量值', () => {
185
+ it('CARD_ERROR.RATE_LIMITED === 230020', () => {
186
+ expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
187
+ })
188
+ it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
189
+ expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
190
+ })
191
+ it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
192
+ expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
193
+ })
194
+ })
adapters/feishu/__tests__/cardkit.test.ts ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * cardkit.ts 单元测试
3
+ *
4
+ * 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
5
+ * - 每个函数构造的 payload 结构
6
+ * - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
7
+ * - 缺失关键字段时抛错
8
+ * - sequence 正确传递
9
+ */
10
+
11
+ import { describe, it, expect } from 'bun:test'
12
+ import {
13
+ createCardEntity,
14
+ sendCardAsMessage,
15
+ streamCardContent,
16
+ setCardStreamingMode,
17
+ updateCardKitCard,
18
+ CardKitApiError,
19
+ STREAMING_ELEMENT_ID,
20
+ } from '../cardkit.js'
21
+ import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Mock client factory
25
+ // ---------------------------------------------------------------------------
26
+
27
+ type MockCall = { api: string; args: any }
28
+
29
+ function makeMockClient(responses: Record<string, any>) {
30
+ const calls: MockCall[] = []
31
+ const recorder = (api: string, resp: any) => async (args: any) => {
32
+ calls.push({ api, args })
33
+ if (typeof resp === 'function') {
34
+ return resp(args)
35
+ }
36
+ return resp
37
+ }
38
+ const client: any = {
39
+ cardkit: {
40
+ v1: {
41
+ card: {
42
+ create: recorder('cardkit.v1.card.create', responses['card.create']),
43
+ settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
44
+ update: recorder('cardkit.v1.card.update', responses['card.update']),
45
+ },
46
+ cardElement: {
47
+ content: recorder(
48
+ 'cardkit.v1.cardElement.content',
49
+ responses['cardElement.content'],
50
+ ),
51
+ },
52
+ },
53
+ },
54
+ im: {
55
+ message: {
56
+ create: recorder('im.message.create', responses['im.message.create']),
57
+ reply: recorder('im.message.reply', responses['im.message.reply']),
58
+ },
59
+ },
60
+ }
61
+ return { client, calls }
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // createCardEntity
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe('createCardEntity', () => {
69
+ it('构造 card_json payload 并返回 card_id', async () => {
70
+ const { client, calls } = makeMockClient({
71
+ 'card.create': {
72
+ code: 0,
73
+ data: { card_id: 'ck_abc_123' },
74
+ },
75
+ })
76
+ const card = { schema: '2.0', body: { elements: [] } }
77
+ const id = await createCardEntity(client, card)
78
+
79
+ expect(id).toBe('ck_abc_123')
80
+ expect(calls.length).toBe(1)
81
+ expect(calls[0]!.api).toBe('cardkit.v1.card.create')
82
+ expect(calls[0]!.args.data.type).toBe('card_json')
83
+ // data.data 应当是 card 的 JSON 字符串
84
+ expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
85
+ })
86
+
87
+ it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
88
+ const { client } = makeMockClient({
89
+ 'card.create': { code: 0, card_id: 'top_level_id' },
90
+ })
91
+ const id = await createCardEntity(client, {})
92
+ expect(id).toBe('top_level_id')
93
+ })
94
+
95
+ it('non-zero code 抛 CardKitApiError', async () => {
96
+ const { client } = makeMockClient({
97
+ 'card.create': { code: 230099, msg: 'something failed' },
98
+ })
99
+ await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
100
+ })
101
+
102
+ it('code=0 但缺 card_id 抛错', async () => {
103
+ const { client } = makeMockClient({
104
+ 'card.create': { code: 0, data: {} },
105
+ })
106
+ await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
107
+ })
108
+ })
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // sendCardAsMessage
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('sendCardAsMessage', () => {
115
+ it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
116
+ const { client, calls } = makeMockClient({
117
+ 'im.message.create': { data: { message_id: 'om_new_msg_1' } },
118
+ })
119
+ const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
120
+ expect(mid).toBe('om_new_msg_1')
121
+ expect(calls.length).toBe(1)
122
+ expect(calls[0]!.api).toBe('im.message.create')
123
+ expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
124
+ expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
125
+ expect(calls[0]!.args.data.msg_type).toBe('interactive')
126
+ // content 格式: {"type":"card","data":{"card_id":"xxx"}}
127
+ const parsed = JSON.parse(calls[0]!.args.data.content)
128
+ expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
129
+ })
130
+
131
+ it('有 replyTo: 走 im.message.reply', async () => {
132
+ const { client, calls } = makeMockClient({
133
+ 'im.message.reply': { data: { message_id: 'om_reply_1' } },
134
+ })
135
+ const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
136
+ expect(mid).toBe('om_reply_1')
137
+ expect(calls.length).toBe(1)
138
+ expect(calls[0]!.api).toBe('im.message.reply')
139
+ expect(calls[0]!.args.path.message_id).toBe('om_parent')
140
+ const parsed = JSON.parse(calls[0]!.args.data.content)
141
+ expect(parsed.data.card_id).toBe('ck_id_xyz')
142
+ })
143
+
144
+ it('缺 message_id 抛错', async () => {
145
+ const { client } = makeMockClient({
146
+ 'im.message.create': { data: {} },
147
+ })
148
+ await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
149
+ /missing message_id/,
150
+ )
151
+ })
152
+ })
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // streamCardContent
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('streamCardContent', () => {
159
+ it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
160
+ const { client, calls } = makeMockClient({
161
+ 'cardElement.content': { code: 0 },
162
+ })
163
+ await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
164
+
165
+ expect(calls.length).toBe(1)
166
+ expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
167
+ expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
168
+ expect(calls[0]!.args.path).toEqual({
169
+ card_id: 'ck_abc',
170
+ element_id: STREAMING_ELEMENT_ID,
171
+ })
172
+ })
173
+
174
+ it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
175
+ expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
176
+ })
177
+
178
+ it('230020 响应可被 isCardRateLimitError 识别', async () => {
179
+ const { client } = makeMockClient({
180
+ 'cardElement.content': { code: 230020, msg: 'rate limited' },
181
+ })
182
+ try {
183
+ await streamCardContent(client, 'ck', 'el', 'x', 1)
184
+ expect('should have thrown').toBe('but did not')
185
+ } catch (err) {
186
+ expect(err).toBeInstanceOf(CardKitApiError)
187
+ expect(isCardRateLimitError(err)).toBe(true)
188
+ }
189
+ })
190
+
191
+ it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
192
+ const { client } = makeMockClient({
193
+ 'cardElement.content': {
194
+ code: 230099,
195
+ msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
196
+ },
197
+ })
198
+ try {
199
+ await streamCardContent(client, 'ck', 'el', 'x', 1)
200
+ expect('should have thrown').toBe('but did not')
201
+ } catch (err) {
202
+ expect(err).toBeInstanceOf(CardKitApiError)
203
+ expect(isCardTableLimitError(err)).toBe(true)
204
+ }
205
+ })
206
+ })
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // setCardStreamingMode
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe('setCardStreamingMode', () => {
213
+ it('streaming_mode=false + sequence 正确传递', async () => {
214
+ const { client, calls } = makeMockClient({
215
+ 'card.settings': { code: 0 },
216
+ })
217
+ await setCardStreamingMode(client, 'ck_xxx', false, 99)
218
+
219
+ expect(calls.length).toBe(1)
220
+ expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
221
+ expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
222
+ expect(calls[0]!.args.data.sequence).toBe(99)
223
+ // settings 是 JSON 字符串
224
+ const settings = JSON.parse(calls[0]!.args.data.settings)
225
+ expect(settings).toEqual({ streaming_mode: false })
226
+ })
227
+
228
+ it('streaming_mode=true 也能工作', async () => {
229
+ const { client, calls } = makeMockClient({
230
+ 'card.settings': { code: 0 },
231
+ })
232
+ await setCardStreamingMode(client, 'ck', true, 1)
233
+ const settings = JSON.parse(calls[0]!.args.data.settings)
234
+ expect(settings).toEqual({ streaming_mode: true })
235
+ })
236
+ })
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // updateCardKitCard
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe('updateCardKitCard', () => {
243
+ it('把 card 包装成 card_json payload + sequence', async () => {
244
+ const { client, calls } = makeMockClient({
245
+ 'card.update': { code: 0 },
246
+ })
247
+ const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
248
+ await updateCardKitCard(client, 'ck_final', card, 100)
249
+
250
+ expect(calls.length).toBe(1)
251
+ expect(calls[0]!.api).toBe('cardkit.v1.card.update')
252
+ expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
253
+ expect(calls[0]!.args.data.sequence).toBe(100)
254
+ expect(calls[0]!.args.data.card.type).toBe('card_json')
255
+ expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
256
+ })
257
+
258
+ it('非零 code 抛 CardKitApiError', async () => {
259
+ const { client } = makeMockClient({
260
+ 'card.update': { code: -1, msg: 'bad card' },
261
+ })
262
+ await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
263
+ })
264
+ })
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // CardKitApiError
268
+ // ---------------------------------------------------------------------------
269
+
270
+ describe('CardKitApiError', () => {
271
+ it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
272
+ const err = new CardKitApiError({
273
+ api: 'card.update',
274
+ code: 230020,
275
+ msg: 'rate limited',
276
+ context: 'seq=5',
277
+ })
278
+ expect(err.code).toBe(230020)
279
+ expect(err.msg).toBe('rate limited')
280
+ expect(err.name).toBe('CardKitApiError')
281
+ expect(isCardRateLimitError(err)).toBe(true)
282
+ })
283
+
284
+ it('消息包含 api 名和 context', () => {
285
+ const err = new CardKitApiError({
286
+ api: 'cardElement.content',
287
+ code: 230099,
288
+ msg: 'oops',
289
+ context: 'seq=3 len=100',
290
+ })
291
+ expect(err.message).toContain('cardElement.content')
292
+ expect(err.message).toContain('230099')
293
+ expect(err.message).toContain('seq=3 len=100')
294
+ })
295
+ })
adapters/feishu/__tests__/extract-payload.test.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { extractInboundPayload } from '../extract-payload.js'
3
+
4
+ describe('extractInboundPayload', () => {
5
+ it('pulls text out of a text message', () => {
6
+ const result = extractInboundPayload(
7
+ JSON.stringify({ text: 'hello world' }),
8
+ 'text',
9
+ )
10
+ expect(result.text).toBe('hello world')
11
+ expect(result.pendingDownloads).toEqual([])
12
+ })
13
+
14
+ it('pulls text out of a post (rich text) message', () => {
15
+ const content = JSON.stringify({
16
+ zh_cn: {
17
+ content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]],
18
+ },
19
+ })
20
+ const result = extractInboundPayload(content, 'post')
21
+ expect(result.text).toBe('hi there')
22
+ expect(result.pendingDownloads).toEqual([])
23
+ })
24
+
25
+ it('identifies an image message as a pending image download', () => {
26
+ const content = JSON.stringify({ image_key: 'img_key_abc' })
27
+ const result = extractInboundPayload(content, 'image')
28
+ expect(result.text).toBe('')
29
+ expect(result.pendingDownloads).toEqual([
30
+ { kind: 'image', fileKey: 'img_key_abc' },
31
+ ])
32
+ })
33
+
34
+ it('identifies a file message as a pending file download with file_name', () => {
35
+ const content = JSON.stringify({
36
+ file_key: 'file_key_xyz',
37
+ file_name: 'spec.pdf',
38
+ })
39
+ const result = extractInboundPayload(content, 'file')
40
+ expect(result.pendingDownloads).toEqual([
41
+ { kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' },
42
+ ])
43
+ })
44
+
45
+ it('identifies file_archive the same way as file', () => {
46
+ const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' })
47
+ const result = extractInboundPayload(content, 'file_archive')
48
+ expect(result.pendingDownloads).toEqual([
49
+ { kind: 'file', fileKey: 'fk1', fileName: 'x.zip' },
50
+ ])
51
+ })
52
+
53
+ it('extracts img + file elements from a post message', () => {
54
+ const content = JSON.stringify({
55
+ zh_cn: {
56
+ content: [
57
+ [{ tag: 'text', text: 'look: ' }],
58
+ [{ tag: 'img', image_key: 'img_post_1' }],
59
+ [{ tag: 'text', text: ' and ' }],
60
+ [{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }],
61
+ ],
62
+ },
63
+ })
64
+ const result = extractInboundPayload(content, 'post')
65
+ expect(result.text).toBe('look: and ')
66
+ expect(result.pendingDownloads).toEqual([
67
+ { kind: 'image', fileKey: 'img_post_1' },
68
+ { kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' },
69
+ ])
70
+ })
71
+
72
+ it('returns empty on malformed JSON', () => {
73
+ const result = extractInboundPayload('not json', 'text')
74
+ expect(result.text).toBe('')
75
+ expect(result.pendingDownloads).toEqual([])
76
+ })
77
+ })
adapters/feishu/__tests__/feishu.test.ts ADDED
@@ -0,0 +1,899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 飞书 Adapter 翻译逻辑测试
3
+ *
4
+ * 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
5
+ */
6
+
7
+ import { describe, it, expect } from 'bun:test'
8
+ import { isOutsideWorkDir } from '../path-safety.js'
9
+
10
+ // ---------- helpers extracted from feishu/index.ts for testability ----------
11
+
12
+ function extractText(content: string, msgType: string): string | null {
13
+ try {
14
+ const parsed = JSON.parse(content)
15
+ if (msgType === 'text') {
16
+ return parsed.text ?? null
17
+ }
18
+ if (msgType === 'post') {
19
+ const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
20
+ return zhContent
21
+ .flat()
22
+ .filter((n: any) => n.tag === 'text' || n.tag === 'md')
23
+ .map((n: any) => n.text ?? n.content ?? '')
24
+ .join('')
25
+ .trim() || null
26
+ }
27
+ return null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ function isBotMentioned(
34
+ mentions: Array<{ id?: { open_id?: string } }> | undefined,
35
+ botOpenId: string,
36
+ ): boolean {
37
+ if (!mentions || !botOpenId) return false
38
+ return mentions.some((m) => m.id?.open_id === botOpenId)
39
+ }
40
+
41
+ function stripMentions(text: string): string {
42
+ return text.replace(/@_user_\d+/g, '').trim()
43
+ }
44
+
45
+ type RecentProject = {
46
+ projectPath: string
47
+ realPath: string
48
+ projectName: string
49
+ isGit: boolean
50
+ repoName: string | null
51
+ branch: string | null
52
+ modifiedAt: string
53
+ sessionCount: number
54
+ }
55
+
56
+ function prettyPath(realPath: string, maxLen = 64): string {
57
+ const home = process.env.HOME
58
+ let p = realPath
59
+ if (home) {
60
+ if (p === home) return '~'
61
+ if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
62
+ }
63
+ if (p.length <= maxLen) return p
64
+ const tailLen = Math.floor(maxLen * 0.65)
65
+ const headLen = maxLen - tailLen - 1
66
+ return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
67
+ }
68
+
69
+ function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
70
+ const items = projects.slice(0, 10)
71
+ const total = projects.length
72
+ const subtitleText =
73
+ total > items.length
74
+ ? `共 ${total} 个最近项目,显示前 ${items.length}`
75
+ : `共 ${total} 个最近项目`
76
+
77
+ const rows = items.map((p, i) => {
78
+ const branch = p.branch ? ` · *${p.branch}*` : ''
79
+ return {
80
+ tag: 'column_set',
81
+ flex_mode: 'stretch',
82
+ horizontal_spacing: '8px',
83
+ margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
84
+ columns: [
85
+ {
86
+ tag: 'column',
87
+ width: 'weighted',
88
+ weight: 1,
89
+ vertical_align: 'center',
90
+ elements: [
91
+ {
92
+ tag: 'markdown',
93
+ content: `**${p.projectName}**${branch}`,
94
+ },
95
+ {
96
+ tag: 'markdown',
97
+ content: prettyPath(p.realPath, 56),
98
+ text_size: 'notation',
99
+ margin: '2px 0 0 0',
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ tag: 'column',
105
+ width: 'auto',
106
+ vertical_align: 'center',
107
+ elements: [
108
+ {
109
+ tag: 'button',
110
+ text: { tag: 'plain_text', content: '选择' },
111
+ type: i === 0 ? 'primary' : 'default',
112
+ size: 'small',
113
+ value: {
114
+ action: 'pick_project',
115
+ realPath: p.realPath,
116
+ projectName: p.projectName,
117
+ },
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ }
123
+ })
124
+
125
+ return {
126
+ schema: '2.0',
127
+ config: {
128
+ wide_screen_mode: true,
129
+ update_multi: true,
130
+ },
131
+ header: {
132
+ title: { tag: 'plain_text', content: '📁 选择项目' },
133
+ subtitle: { tag: 'plain_text', content: subtitleText },
134
+ template: 'blue',
135
+ },
136
+ body: {
137
+ elements: [
138
+ ...rows,
139
+ { tag: 'hr', margin: '14px 0 0 0' },
140
+ {
141
+ tag: 'markdown',
142
+ content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
143
+ text_size: 'notation',
144
+ margin: '6px 0 0 0',
145
+ },
146
+ ],
147
+ },
148
+ }
149
+ }
150
+
151
+ // ---------- permission card helpers (mirrored from feishu/index.ts) ----------
152
+
153
+ type ToolCallSummary = {
154
+ icon: string
155
+ label: string
156
+ target?: string
157
+ filePath?: string
158
+ }
159
+
160
+ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
161
+ const rec: Record<string, unknown> =
162
+ input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
163
+ const str = (key: string): string | undefined =>
164
+ typeof rec[key] === 'string' ? (rec[key] as string) : undefined
165
+
166
+ switch (toolName) {
167
+ case 'Write': {
168
+ const fp = str('file_path')
169
+ return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
170
+ }
171
+ case 'Edit':
172
+ case 'MultiEdit':
173
+ case 'NotebookEdit': {
174
+ const fp = str('file_path') ?? str('notebook_path')
175
+ return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
176
+ }
177
+ case 'Read': {
178
+ const fp = str('file_path')
179
+ return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
180
+ }
181
+ case 'Bash':
182
+ case 'BashOutput': {
183
+ return { icon: '🖥️', label: '执行命令', target: str('command') }
184
+ }
185
+ case 'Grep': {
186
+ const pattern = str('pattern')
187
+ return {
188
+ icon: '🔍',
189
+ label: '搜索内容',
190
+ target: pattern ? `pattern: ${pattern}` : undefined,
191
+ filePath: str('path'),
192
+ }
193
+ }
194
+ case 'Glob': {
195
+ const pattern = str('pattern')
196
+ return {
197
+ icon: '📁',
198
+ label: '查找文件',
199
+ target: pattern ? `pattern: ${pattern}` : undefined,
200
+ filePath: str('path'),
201
+ }
202
+ }
203
+ case 'WebFetch':
204
+ return { icon: '🌐', label: '访问网页', target: str('url') }
205
+ case 'WebSearch':
206
+ return { icon: '🌐', label: '搜索网页', target: str('query') }
207
+ default:
208
+ return { icon: '🔧', label: toolName }
209
+ }
210
+ }
211
+
212
+ function truncateTarget(s: string, maxLen = 160): string {
213
+ if (s.length <= maxLen) return s
214
+ return s.slice(0, maxLen - 1) + '…'
215
+ }
216
+
217
+ function buildPermissionCard(
218
+ toolName: string,
219
+ input: unknown,
220
+ requestId: string,
221
+ workDir?: string,
222
+ ): Record<string, unknown> {
223
+ const summary = summarizeToolCall(toolName, input)
224
+ const crossDir = Boolean(
225
+ workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
226
+ )
227
+
228
+ const elements: Record<string, unknown>[] = [
229
+ {
230
+ tag: 'markdown',
231
+ content: `${summary.icon} **${summary.label}** \`${toolName}\``,
232
+ },
233
+ ]
234
+
235
+ if (summary.target) {
236
+ const shown = summary.filePath
237
+ ? prettyPath(summary.target, 80)
238
+ : truncateTarget(summary.target, 160)
239
+ elements.push({
240
+ tag: 'markdown',
241
+ content: '```\n' + shown + '\n```',
242
+ margin: '4px 0 0 0',
243
+ })
244
+ }
245
+
246
+ if (crossDir) {
247
+ elements.push({
248
+ tag: 'markdown',
249
+ content: '⚠️ **该操作位于当前项目目录之外**',
250
+ margin: '8px 0 0 0',
251
+ text_size: 'notation',
252
+ })
253
+ }
254
+
255
+ elements.push({ tag: 'hr', margin: '12px 0 0 0' })
256
+
257
+ elements.push({
258
+ tag: 'column_set',
259
+ flex_mode: 'stretch',
260
+ horizontal_spacing: '8px',
261
+ margin: '8px 0 0 0',
262
+ columns: [
263
+ {
264
+ tag: 'column',
265
+ width: 'weighted',
266
+ weight: 1,
267
+ vertical_align: 'center',
268
+ elements: [
269
+ {
270
+ tag: 'button',
271
+ text: { tag: 'plain_text', content: '✅ 允许' },
272
+ type: 'primary',
273
+ size: 'medium',
274
+ value: { action: 'permit', requestId, allowed: true },
275
+ },
276
+ ],
277
+ },
278
+ {
279
+ tag: 'column',
280
+ width: 'weighted',
281
+ weight: 1,
282
+ vertical_align: 'center',
283
+ elements: [
284
+ {
285
+ tag: 'button',
286
+ text: { tag: 'plain_text', content: '♾️ 永久允许' },
287
+ type: 'default',
288
+ size: 'medium',
289
+ value: { action: 'permit', requestId, allowed: true, rule: 'always' },
290
+ },
291
+ ],
292
+ },
293
+ {
294
+ tag: 'column',
295
+ width: 'weighted',
296
+ weight: 1,
297
+ vertical_align: 'center',
298
+ elements: [
299
+ {
300
+ tag: 'button',
301
+ text: { tag: 'plain_text', content: '❌ 拒绝' },
302
+ type: 'danger',
303
+ size: 'medium',
304
+ value: { action: 'permit', requestId, allowed: false },
305
+ },
306
+ ],
307
+ },
308
+ ],
309
+ })
310
+
311
+ return {
312
+ schema: '2.0',
313
+ config: {
314
+ wide_screen_mode: false,
315
+ update_multi: true,
316
+ },
317
+ header: {
318
+ title: { tag: 'plain_text', content: '🔐 需要权限确认' },
319
+ subtitle: {
320
+ tag: 'plain_text',
321
+ content: crossDir ? '⚠️ 跨目录操作' : toolName,
322
+ },
323
+ template: crossDir ? 'red' : 'orange',
324
+ padding: '12px 12px 12px 12px',
325
+ icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
326
+ },
327
+ body: { elements },
328
+ }
329
+ }
330
+
331
+ // ---------- tests ----------
332
+
333
+ describe('Feishu: event parsing', () => {
334
+ describe('extractText', () => {
335
+ it('extracts text from text message', () => {
336
+ const content = JSON.stringify({ text: 'hello world' })
337
+ expect(extractText(content, 'text')).toBe('hello world')
338
+ })
339
+
340
+ it('extracts text from post message (zh_cn)', () => {
341
+ const content = JSON.stringify({
342
+ zh_cn: {
343
+ content: [[
344
+ { tag: 'text', text: 'Hello ' },
345
+ { tag: 'text', text: 'World' },
346
+ ]],
347
+ },
348
+ })
349
+ expect(extractText(content, 'post')).toBe('Hello World')
350
+ })
351
+
352
+ it('extracts text from post message with md tag', () => {
353
+ const content = JSON.stringify({
354
+ zh_cn: {
355
+ content: [[{ tag: 'md', text: '**bold** text' }]],
356
+ },
357
+ })
358
+ expect(extractText(content, 'post')).toBe('**bold** text')
359
+ })
360
+
361
+ it('returns null for unsupported message types', () => {
362
+ expect(extractText('{}', 'image')).toBeNull()
363
+ expect(extractText('{}', 'audio')).toBeNull()
364
+ })
365
+
366
+ it('returns null for malformed content', () => {
367
+ expect(extractText('not-json', 'text')).toBeNull()
368
+ })
369
+
370
+ it('returns null for empty text', () => {
371
+ const content = JSON.stringify({ text: '' })
372
+ // empty string is falsy, so ?? null returns ''
373
+ expect(extractText(content, 'text')).toBe('')
374
+ })
375
+ })
376
+
377
+ describe('isBotMentioned', () => {
378
+ const botId = 'ou_bot_123'
379
+
380
+ it('returns true when bot is mentioned', () => {
381
+ const mentions = [
382
+ { id: { open_id: 'ou_user_1' } },
383
+ { id: { open_id: 'ou_bot_123' } },
384
+ ]
385
+ expect(isBotMentioned(mentions, botId)).toBe(true)
386
+ })
387
+
388
+ it('returns false when bot is not mentioned', () => {
389
+ const mentions = [
390
+ { id: { open_id: 'ou_user_1' } },
391
+ { id: { open_id: 'ou_user_2' } },
392
+ ]
393
+ expect(isBotMentioned(mentions, botId)).toBe(false)
394
+ })
395
+
396
+ it('returns false for undefined mentions', () => {
397
+ expect(isBotMentioned(undefined, botId)).toBe(false)
398
+ })
399
+
400
+ it('returns false for empty mentions', () => {
401
+ expect(isBotMentioned([], botId)).toBe(false)
402
+ })
403
+ })
404
+
405
+ describe('stripMentions', () => {
406
+ it('removes @_user_N patterns', () => {
407
+ expect(stripMentions('@_user_1 hello world')).toBe('hello world')
408
+ })
409
+
410
+ it('removes multiple mentions', () => {
411
+ expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
412
+ })
413
+
414
+ it('leaves text without mentions unchanged', () => {
415
+ expect(stripMentions('hello world')).toBe('hello world')
416
+ })
417
+
418
+ it('trims whitespace', () => {
419
+ expect(stripMentions(' @_user_1 hello ')).toBe('hello')
420
+ })
421
+ })
422
+ })
423
+
424
+ describe('Feishu: permission card', () => {
425
+ // Helpers to reach into Schema 2.0 body.elements
426
+ function getBodyElements(card: Record<string, unknown>): any[] {
427
+ return ((card.body as any).elements ?? []) as any[]
428
+ }
429
+ function getActionRow(card: Record<string, unknown>): any {
430
+ return getBodyElements(card).find((el) => el.tag === 'column_set')
431
+ }
432
+ function getButtons(card: Record<string, unknown>): any[] {
433
+ return getActionRow(card).columns.map(
434
+ (c: any) => c.elements.find((e: any) => e.tag === 'button'),
435
+ )
436
+ }
437
+
438
+ // ----- Schema 2.0 regression -----
439
+
440
+ it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
441
+ const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
442
+ expect(card.schema).toBe('2.0')
443
+ expect(card.elements).toBeUndefined() // old bug had top-level elements
444
+ expect((card.body as any).elements).toBeDefined()
445
+ expect((card.config as any).update_multi).toBe(true)
446
+ expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
447
+ })
448
+
449
+ it('header has title, subtitle, template, icon', () => {
450
+ const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
451
+ const header = card.header as any
452
+ expect(header.title.content).toContain('权限确认')
453
+ expect(header.subtitle.content).toBe('Bash')
454
+ expect(header.template).toBe('orange')
455
+ expect(header.icon.tag).toBe('standard_icon')
456
+ })
457
+
458
+ // ----- Three buttons -----
459
+
460
+ it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
461
+ const card = buildPermissionCard('Read', {}, 'xyz')
462
+ const [allow, always, deny] = getButtons(card)
463
+ expect(allow.text.content).toContain('允许')
464
+ expect(allow.type).toBe('primary')
465
+ expect(always.text.content).toContain('永久允许')
466
+ expect(always.type).toBe('default')
467
+ expect(deny.text.content).toContain('拒绝')
468
+ expect(deny.type).toBe('danger')
469
+ })
470
+
471
+ it('允许 button carries allowed=true and no rule', () => {
472
+ const card = buildPermissionCard('Read', {}, 'req-1')
473
+ const [allow] = getButtons(card)
474
+ expect(allow.value).toEqual({
475
+ action: 'permit',
476
+ requestId: 'req-1',
477
+ allowed: true,
478
+ })
479
+ expect(allow.value.rule).toBeUndefined()
480
+ })
481
+
482
+ it('永久允许 button carries allowed=true + rule=always', () => {
483
+ const card = buildPermissionCard('Read', {}, 'req-2')
484
+ const always = getButtons(card)[1]
485
+ expect(always.value).toEqual({
486
+ action: 'permit',
487
+ requestId: 'req-2',
488
+ allowed: true,
489
+ rule: 'always',
490
+ })
491
+ })
492
+
493
+ it('拒绝 button carries allowed=false and no rule', () => {
494
+ const card = buildPermissionCard('Read', {}, 'req-3')
495
+ const deny = getButtons(card)[2]
496
+ expect(deny.value).toEqual({
497
+ action: 'permit',
498
+ requestId: 'req-3',
499
+ allowed: false,
500
+ })
501
+ })
502
+
503
+ // ----- Tool summary rendering -----
504
+
505
+ it('renders Write with ✏️ 写入文件 header and file path target', () => {
506
+ const card = buildPermissionCard(
507
+ 'Write',
508
+ { file_path: '/tmp/output.txt', content: 'hi' },
509
+ 'req',
510
+ )
511
+ const elements = getBodyElements(card)
512
+ expect(elements[0].content).toContain('✏️')
513
+ expect(elements[0].content).toContain('写入文件')
514
+ expect(elements[0].content).toContain('`Write`')
515
+ // Target rendered as fenced code block
516
+ expect(elements[1].content).toContain('/tmp/output.txt')
517
+ expect(elements[1].content.startsWith('```')).toBe(true)
518
+ })
519
+
520
+ it('renders Edit with ✏️ 修改文件', () => {
521
+ const card = buildPermissionCard(
522
+ 'Edit',
523
+ { file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
524
+ 'req',
525
+ )
526
+ expect(getBodyElements(card)[0].content).toContain('修改文件')
527
+ })
528
+
529
+ it('renders Bash with 🖥️ 执行命令 and command target', () => {
530
+ const card = buildPermissionCard(
531
+ 'Bash',
532
+ { command: 'rm -rf /tmp/x' },
533
+ 'req',
534
+ )
535
+ const elements = getBodyElements(card)
536
+ expect(elements[0].content).toContain('🖥️')
537
+ expect(elements[0].content).toContain('执行命令')
538
+ expect(elements[1].content).toContain('rm -rf /tmp/x')
539
+ })
540
+
541
+ it('truncates very long Bash commands to 160 chars', () => {
542
+ const longCmd = 'echo ' + 'x'.repeat(500)
543
+ const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
544
+ const targetEl = getBodyElements(card)[1]
545
+ expect(targetEl.content).toContain('…')
546
+ // Fenced code wraps ~10 extra chars
547
+ expect(targetEl.content.length).toBeLessThanOrEqual(180)
548
+ })
549
+
550
+ it('renders Grep with 🔍 搜索内容 and pattern target', () => {
551
+ const card = buildPermissionCard(
552
+ 'Grep',
553
+ { pattern: 'TODO', path: '/src' },
554
+ 'req',
555
+ )
556
+ const elements = getBodyElements(card)
557
+ expect(elements[0].content).toContain('🔍')
558
+ expect(elements[1].content).toContain('TODO')
559
+ })
560
+
561
+ it('renders WebFetch with 🌐 访问网页 and url target', () => {
562
+ const card = buildPermissionCard(
563
+ 'WebFetch',
564
+ { url: 'https://example.com/api' },
565
+ 'req',
566
+ )
567
+ const elements = getBodyElements(card)
568
+ expect(elements[0].content).toContain('🌐')
569
+ expect(elements[0].content).toContain('访问网页')
570
+ expect(elements[1].content).toContain('https://example.com/api')
571
+ })
572
+
573
+ it('falls back to 🔧 <toolName> for unknown tools', () => {
574
+ const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
575
+ expect(getBodyElements(card)[0].content).toContain('🔧')
576
+ expect(getBodyElements(card)[0].content).toContain('CustomTool')
577
+ })
578
+
579
+ it('has no target line when input is empty', () => {
580
+ const card = buildPermissionCard('Bash', {}, 'req')
581
+ const elements = getBodyElements(card)
582
+ // elements: [header_md, hr, action_column_set]
583
+ expect(elements[1].tag).toBe('hr')
584
+ })
585
+
586
+ // ----- Cross-directory detection -----
587
+
588
+ it('does NOT show cross-dir warning when file is inside workDir', () => {
589
+ const card = buildPermissionCard(
590
+ 'Write',
591
+ { file_path: '/Users/me/proj/src/a.ts' },
592
+ 'req',
593
+ '/Users/me/proj',
594
+ )
595
+ const elements = getBodyElements(card)
596
+ const hasWarn = elements.some(
597
+ (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
598
+ )
599
+ expect(hasWarn).toBe(false)
600
+ expect((card.header as any).template).toBe('orange')
601
+ expect((card.header as any).subtitle.content).toBe('Write')
602
+ })
603
+
604
+ it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
605
+ const card = buildPermissionCard(
606
+ 'Write',
607
+ { file_path: '/tmp/evil.sh' },
608
+ 'req',
609
+ '/Users/me/proj',
610
+ )
611
+ const elements = getBodyElements(card)
612
+ const warn = elements.find(
613
+ (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
614
+ )
615
+ expect(warn).toBeDefined()
616
+ expect((card.header as any).template).toBe('red')
617
+ expect((card.header as any).subtitle.content).toContain('跨目录')
618
+ })
619
+
620
+ it('does NOT check cross-dir for Bash (no filePath)', () => {
621
+ const card = buildPermissionCard(
622
+ 'Bash',
623
+ { command: 'rm -rf /tmp/x' },
624
+ 'req',
625
+ '/Users/me/proj',
626
+ )
627
+ expect((card.header as any).template).toBe('orange')
628
+ })
629
+
630
+ it('does not warn when workDir is not provided', () => {
631
+ const card = buildPermissionCard(
632
+ 'Write',
633
+ { file_path: '/tmp/x.ts' },
634
+ 'req',
635
+ // workDir omitted
636
+ )
637
+ const elements = getBodyElements(card)
638
+ const hasWarn = elements.some(
639
+ (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
640
+ )
641
+ expect(hasWarn).toBe(false)
642
+ })
643
+ })
644
+
645
+ describe('Feishu: isOutsideWorkDir', () => {
646
+ it('returns false for file inside workDir', () => {
647
+ expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
648
+ })
649
+
650
+ it('returns false for file directly in workDir', () => {
651
+ expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
652
+ })
653
+
654
+ it('returns true for file in a sibling directory', () => {
655
+ expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
656
+ })
657
+
658
+ it('returns true for /tmp file', () => {
659
+ expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
660
+ })
661
+
662
+ it('handles workDir with trailing slash', () => {
663
+ expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
664
+ })
665
+
666
+ it('resolves relative paths against workDir', () => {
667
+ expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
668
+ expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
669
+ })
670
+
671
+ it('does not match prefix collisions (proj vs proj2)', () => {
672
+ // /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
673
+ // but is NOT inside /Users/me/proj
674
+ expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
675
+ })
676
+ })
677
+
678
+ describe('Feishu: project picker card', () => {
679
+ const sampleProjects: RecentProject[] = [
680
+ {
681
+ projectPath: '/Users/dev/claude-code-haha',
682
+ realPath: '/Users/dev/claude-code-haha',
683
+ projectName: 'claude-code-haha',
684
+ isGit: true,
685
+ repoName: 'claude-code-haha',
686
+ branch: 'main',
687
+ modifiedAt: '2026-04-11T00:00:00Z',
688
+ sessionCount: 3,
689
+ },
690
+ {
691
+ projectPath: '/Users/dev/desktop',
692
+ realPath: '/Users/dev/desktop',
693
+ projectName: 'desktop',
694
+ isGit: false,
695
+ repoName: null,
696
+ branch: null,
697
+ modifiedAt: '2026-04-10T00:00:00Z',
698
+ sessionCount: 1,
699
+ },
700
+ ]
701
+
702
+ function getBodyElements(card: Record<string, unknown>): any[] {
703
+ return ((card.body as any).elements ?? []) as any[]
704
+ }
705
+
706
+ function getRows(card: Record<string, unknown>): any[] {
707
+ return getBodyElements(card).filter((el) => el.tag === 'column_set')
708
+ }
709
+
710
+ function getRowButton(row: any): any {
711
+ const buttonCol = row.columns.find((c: any) =>
712
+ c.elements.some((e: any) => e.tag === 'button'),
713
+ )
714
+ return buttonCol.elements.find((e: any) => e.tag === 'button')
715
+ }
716
+
717
+ function getRowInfoElements(row: any): any[] {
718
+ const infoCol = row.columns.find((c: any) =>
719
+ c.elements.every((e: any) => e.tag === 'markdown'),
720
+ )
721
+ return infoCol.elements
722
+ }
723
+
724
+ it('uses Schema 2.0 with body.elements wrapper', () => {
725
+ const card = buildProjectPickerCard(sampleProjects)
726
+ expect(card.schema).toBe('2.0')
727
+ expect((card.config as any).update_multi).toBe(true)
728
+ expect((card.body as any).elements).toBeDefined()
729
+ })
730
+
731
+ it('header has title and project-count subtitle', () => {
732
+ const card = buildProjectPickerCard(sampleProjects)
733
+ expect((card.header as any).title.content).toContain('选择项目')
734
+ expect((card.header as any).subtitle.content).toContain('2')
735
+ expect((card.header as any).subtitle.content).toContain('最近项目')
736
+ })
737
+
738
+ it('subtitle notes truncation when more than 10 projects exist', () => {
739
+ const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
740
+ ...sampleProjects[0]!,
741
+ projectName: `proj-${i}`,
742
+ realPath: `/p/${i}`,
743
+ }))
744
+ const card = buildProjectPickerCard(many)
745
+ const subtitle = (card.header as any).subtitle.content
746
+ expect(subtitle).toContain('15')
747
+ expect(subtitle).toContain('显示前 10')
748
+ })
749
+
750
+ it('body contains one column_set row per project', () => {
751
+ const card = buildProjectPickerCard(sampleProjects)
752
+ expect(getRows(card).length).toBe(2)
753
+ })
754
+
755
+ it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
756
+ const card = buildProjectPickerCard(sampleProjects)
757
+ for (const row of getRows(card)) {
758
+ expect(row.columns.length).toBe(2)
759
+ expect(row.columns[0].width).toBe('weighted')
760
+ expect(row.columns[0].vertical_align).toBe('center')
761
+ expect(row.columns[1].width).toBe('auto')
762
+ expect(row.columns[1].vertical_align).toBe('center')
763
+ }
764
+ })
765
+
766
+ it('info column has title markdown + notation path markdown', () => {
767
+ const card = buildProjectPickerCard(sampleProjects)
768
+ const row1 = getRows(card)[0]
769
+ const info = getRowInfoElements(row1)
770
+
771
+ expect(info.length).toBe(2)
772
+ // Title markdown
773
+ expect(info[0].tag).toBe('markdown')
774
+ expect(info[0].content).toContain('**claude-code-haha**')
775
+ expect(info[0].content).toContain('*main*')
776
+ // Path markdown (notation = small grey)
777
+ expect(info[1].tag).toBe('markdown')
778
+ expect(info[1].text_size).toBe('notation')
779
+ expect(info[1].content).toContain('claude-code-haha')
780
+ })
781
+
782
+ it('row without branch has no separator dot in title', () => {
783
+ const card = buildProjectPickerCard(sampleProjects)
784
+ const row2 = getRows(card)[1]
785
+ const title = getRowInfoElements(row2)[0].content
786
+ expect(title).toContain('**desktop**')
787
+ expect(title).not.toContain('·')
788
+ })
789
+
790
+ it('row button says 选择 with small size and carries per-project value', () => {
791
+ const card = buildProjectPickerCard(sampleProjects)
792
+ const rows = getRows(card)
793
+
794
+ const btn1 = getRowButton(rows[0])
795
+ expect(btn1.text.content).toBe('选择')
796
+ expect(btn1.size).toBe('small')
797
+ expect(btn1.value.action).toBe('pick_project')
798
+ expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
799
+ expect(btn1.value.projectName).toBe('claude-code-haha')
800
+
801
+ const btn2 = getRowButton(rows[1])
802
+ expect(btn2.value.realPath).toBe('/Users/dev/desktop')
803
+ })
804
+
805
+ it('first row button is primary, rest are default', () => {
806
+ const card = buildProjectPickerCard(sampleProjects)
807
+ const rows = getRows(card)
808
+ expect(getRowButton(rows[0]).type).toBe('primary')
809
+ expect(getRowButton(rows[1]).type).toBe('default')
810
+ })
811
+
812
+ it('body tail has hr and notation footer hint', () => {
813
+ const card = buildProjectPickerCard(sampleProjects)
814
+ const elements = getBodyElements(card)
815
+ const hrIdx = elements.findIndex((el) => el.tag === 'hr')
816
+ expect(hrIdx).toBeGreaterThan(0)
817
+ expect(elements[hrIdx + 1].tag).toBe('markdown')
818
+ expect(elements[hrIdx + 1].text_size).toBe('notation')
819
+ })
820
+
821
+ it('caps to first 10 projects', () => {
822
+ const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
823
+ ...sampleProjects[0]!,
824
+ projectName: `proj-${i}`,
825
+ realPath: `/p/${i}`,
826
+ }))
827
+ const card = buildProjectPickerCard(many)
828
+ const rows = getRows(card)
829
+ expect(rows.length).toBe(10)
830
+ expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
831
+ })
832
+
833
+ it('uses ~ shortcut when path is under $HOME', () => {
834
+ const home = process.env.HOME
835
+ if (!home) return
836
+ const project: RecentProject = {
837
+ ...sampleProjects[0]!,
838
+ realPath: `${home}/some/sub/dir`,
839
+ projectName: 'sub-dir',
840
+ }
841
+ const card = buildProjectPickerCard([project])
842
+ const pathEl = getRowInfoElements(getRows(card)[0])[1]
843
+ expect(pathEl.content).toBe('~/some/sub/dir')
844
+ })
845
+
846
+ it('middle-truncates very long paths with ellipsis', () => {
847
+ const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
848
+ const project: RecentProject = {
849
+ ...sampleProjects[0]!,
850
+ realPath: veryLong,
851
+ projectName: 'project',
852
+ }
853
+ const card = buildProjectPickerCard([project])
854
+ const content = getRowInfoElements(getRows(card)[0])[1].content
855
+ expect(content).toContain('…')
856
+ expect(content.length).toBeLessThanOrEqual(56)
857
+ expect(content.endsWith('project')).toBe(true)
858
+ })
859
+ })
860
+
861
+ describe('Feishu: card.action.trigger parsing', () => {
862
+ it('parses permit action from event', () => {
863
+ const event = {
864
+ operator: { open_id: 'ou_user_1' },
865
+ action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
866
+ context: { open_chat_id: 'oc_chat_123' },
867
+ }
868
+
869
+ expect(event.action.value.action).toBe('permit')
870
+ expect(event.action.value.requestId).toBe('abcde')
871
+ expect(event.action.value.allowed).toBe(true)
872
+ expect(event.context.open_chat_id).toBe('oc_chat_123')
873
+ })
874
+
875
+ it('parses pick_project action from event', () => {
876
+ const event = {
877
+ operator: { open_id: 'ou_user_1' },
878
+ action: {
879
+ value: {
880
+ action: 'pick_project',
881
+ realPath: '/Users/dev/claude-code-haha',
882
+ projectName: 'claude-code-haha',
883
+ },
884
+ },
885
+ context: { open_chat_id: 'oc_chat_123' },
886
+ }
887
+
888
+ expect(event.action.value.action).toBe('pick_project')
889
+ expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
890
+ expect(event.action.value.projectName).toBe('claude-code-haha')
891
+ })
892
+
893
+ it('ignores non-handled actions', () => {
894
+ const event = {
895
+ action: { value: { action: 'other_action' } },
896
+ }
897
+ expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
898
+ })
899
+ })
adapters/feishu/__tests__/flush-controller.test.ts ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * FlushController 单元测试
3
+ *
4
+ * 覆盖:
5
+ * - 基础: cardMessageReady gate, complete() 锁死
6
+ * - 节流窗口: 立即 flush / 延迟 flush
7
+ * - Mutex: 进行中的 flush 重复调用标记 needsReflush
8
+ * - Conflict reflush: API 结束后自动补一次
9
+ * - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush
10
+ * - waitForFlush: 等当前 flush 结束
11
+ */
12
+
13
+ import { describe, it, expect } from 'bun:test'
14
+ import { FlushController, THROTTLE } from '../flush-controller.js'
15
+
16
+ // 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve
17
+ function makeControllableFlush() {
18
+ const calls: string[] = []
19
+ let resolveCurrent: (() => void) | null = null
20
+ let latch: Promise<void> | null = null
21
+
22
+ const doFlush = async () => {
23
+ calls.push('flush-start')
24
+ if (latch) {
25
+ await latch
26
+ latch = null
27
+ }
28
+ calls.push('flush-end')
29
+ }
30
+
31
+ const blockNext = () => {
32
+ latch = new Promise<void>((resolve) => {
33
+ resolveCurrent = resolve
34
+ })
35
+ }
36
+
37
+ const unblock = () => {
38
+ if (resolveCurrent) {
39
+ const r = resolveCurrent
40
+ resolveCurrent = null
41
+ r()
42
+ }
43
+ }
44
+
45
+ return { doFlush, calls, blockNext, unblock }
46
+ }
47
+
48
+ async function sleep(ms: number): Promise<void> {
49
+ await new Promise((r) => setTimeout(r, ms))
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Basic gating
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('FlushController: cardMessageReady gate', () => {
57
+ it('在 cardMessageReady=false 时不 flush', async () => {
58
+ let count = 0
59
+ const fc = new FlushController(async () => {
60
+ count += 1
61
+ })
62
+ await fc.flush()
63
+ await fc.throttledUpdate(50)
64
+ expect(count).toBe(0)
65
+ })
66
+
67
+ it('setCardMessageReady(true) 后 flush 可执行', async () => {
68
+ let count = 0
69
+ const fc = new FlushController(async () => {
70
+ count += 1
71
+ })
72
+ fc.setCardMessageReady(true)
73
+ await fc.flush()
74
+ expect(count).toBe(1)
75
+ })
76
+
77
+ it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => {
78
+ let count = 0
79
+ const fc = new FlushController(async () => {
80
+ count += 1
81
+ })
82
+ fc.setCardMessageReady(true)
83
+ // 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支
84
+ await fc.throttledUpdate(500)
85
+ // 同步阶段还没到 500ms,不应触发 flush
86
+ expect(count).toBe(0)
87
+ // 500+ms 后延迟 timer 触发
88
+ await sleep(600)
89
+ expect(count).toBe(1)
90
+ })
91
+ })
92
+
93
+ describe('FlushController: complete()', () => {
94
+ it('complete() 后拒绝新 flush', async () => {
95
+ let count = 0
96
+ const fc = new FlushController(async () => {
97
+ count += 1
98
+ })
99
+ fc.setCardMessageReady(true)
100
+ fc.complete()
101
+ await fc.flush()
102
+ await fc.throttledUpdate(50)
103
+ expect(count).toBe(0)
104
+ })
105
+ })
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Throttle window
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('FlushController: 节流窗口', () => {
112
+ it('超过窗口立即 flush', async () => {
113
+ let count = 0
114
+ const fc = new FlushController(async () => {
115
+ count += 1
116
+ })
117
+ fc.setCardMessageReady(true)
118
+ // 手动把 lastUpdateTime 挪远(等同于已过了节流窗口)
119
+ await sleep(150)
120
+ await fc.throttledUpdate(100)
121
+ expect(count).toBe(1)
122
+ })
123
+
124
+ it('在窗口内首次调用安排延迟 flush', async () => {
125
+ let count = 0
126
+ const fc = new FlushController(async () => {
127
+ count += 1
128
+ })
129
+ fc.setCardMessageReady(true) // lastUpdateTime = now
130
+
131
+ await fc.throttledUpdate(200)
132
+ expect(count).toBe(0) // 延迟中,还没触发
133
+
134
+ await sleep(300)
135
+ expect(count).toBe(1) // 200ms 后延迟 timer 触发
136
+ })
137
+
138
+ it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => {
139
+ let count = 0
140
+ const fc = new FlushController(async () => {
141
+ count += 1
142
+ })
143
+ fc.setCardMessageReady(true)
144
+
145
+ await fc.throttledUpdate(200)
146
+ await fc.throttledUpdate(200)
147
+ await fc.throttledUpdate(200)
148
+ await fc.throttledUpdate(200)
149
+
150
+ await sleep(300)
151
+ expect(count).toBe(1)
152
+ })
153
+ })
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Mutex + conflict reflush
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('FlushController: mutex + 冲突重刷', () => {
160
+ it('flush 进行中的重复调用不并发执行', async () => {
161
+ const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
162
+ const fc = new FlushController(doFlush)
163
+ fc.setCardMessageReady(true)
164
+
165
+ blockNext()
166
+ const p1 = fc.flush() // flush-start 后被 latch 卡住
167
+ // 让事件循环走一轮,确保第一次 flush 进入 body
168
+ await sleep(10)
169
+ // 第二次调用时第一次还没结束 —— 应被 mutex 挡住
170
+ const p2 = fc.flush()
171
+
172
+ // 两次 Promise 都已登记,但都还没 end
173
+ expect(calls).toEqual(['flush-start'])
174
+
175
+ unblock()
176
+ await p1
177
+ await p2
178
+ // 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷
179
+ // (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完)
180
+ await sleep(20)
181
+
182
+ // 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end
183
+ expect(calls).toEqual([
184
+ 'flush-start', 'flush-end',
185
+ 'flush-start', 'flush-end',
186
+ ])
187
+ })
188
+
189
+ it('flush 进行中的 throttledUpdate 也会触发补刷', async () => {
190
+ const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
191
+ const fc = new FlushController(doFlush)
192
+ fc.setCardMessageReady(true)
193
+
194
+ blockNext()
195
+ const p1 = fc.flush()
196
+ await sleep(10)
197
+
198
+ // API 进行中收到新的 update 请求
199
+ await fc.throttledUpdate(10)
200
+
201
+ unblock()
202
+ await p1
203
+ await sleep(30)
204
+
205
+ // 第一次 flush + 冲突补刷
206
+ expect(calls.filter((c) => c === 'flush-end').length).toBe(2)
207
+ })
208
+ })
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Long gap batching
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('FlushController: 长间隔批量', () => {
215
+ it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => {
216
+ let count = 0
217
+ let flushAtMs = 0
218
+ const start = Date.now()
219
+ const fc = new FlushController(async () => {
220
+ count += 1
221
+ flushAtMs = Date.now() - start
222
+ })
223
+ fc.setCardMessageReady(true)
224
+
225
+ // 等到 elapsed > 2000ms
226
+ await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50)
227
+ const callAt = Date.now() - start
228
+
229
+ await fc.throttledUpdate(THROTTLE.CARDKIT_MS)
230
+ // throttledUpdate 同步阶段不应立即 flush(因为走批量分支)
231
+ expect(count).toBe(0)
232
+
233
+ // 等 BATCH_AFTER_GAP_MS + 余量
234
+ await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50)
235
+ expect(count).toBe(1)
236
+ // 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms
237
+ expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20)
238
+ })
239
+ })
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // waitForFlush
243
+ // ---------------------------------------------------------------------------
244
+
245
+ describe('FlushController: waitForFlush', () => {
246
+ it('没在 flush 时立即返回', async () => {
247
+ const fc = new FlushController(async () => {})
248
+ fc.setCardMessageReady(true)
249
+ const start = Date.now()
250
+ await fc.waitForFlush()
251
+ expect(Date.now() - start).toBeLessThan(10)
252
+ })
253
+
254
+ it('有 flush 在跑时等它结束', async () => {
255
+ const { doFlush, blockNext, unblock } = makeControllableFlush()
256
+ const fc = new FlushController(doFlush)
257
+ fc.setCardMessageReady(true)
258
+
259
+ blockNext()
260
+ const p1 = fc.flush()
261
+ await sleep(10)
262
+
263
+ let resolved = false
264
+ const waiter = fc.waitForFlush().then(() => {
265
+ resolved = true
266
+ })
267
+ await sleep(20)
268
+ expect(resolved).toBe(false)
269
+
270
+ unblock()
271
+ await p1
272
+ await waiter
273
+ expect(resolved).toBe(true)
274
+ })
275
+ })
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // 常量合理性
279
+ // ---------------------------------------------------------------------------
280
+
281
+ describe('FlushController: THROTTLE 常量', () => {
282
+ it('CARDKIT_MS=100, PATCH_MS=1500', () => {
283
+ expect(THROTTLE.CARDKIT_MS).toBe(100)
284
+ expect(THROTTLE.PATCH_MS).toBe(1500)
285
+ })
286
+ it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => {
287
+ expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000)
288
+ expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300)
289
+ })
290
+ })
adapters/feishu/__tests__/markdown-style.test.ts ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * markdown-style 单元测试
3
+ *
4
+ * 覆盖:
5
+ * - 标题降级 (H1~H3 workaround)
6
+ * - 代码块保护
7
+ * - 空行压缩
8
+ * - Schema 2.0: 连续标题/表格/代码块 <br> 间距
9
+ * - stripInvalidImageKeys
10
+ * - sanitizeTextForCard (表格数限制)
11
+ * - findMarkdownTablesOutsideCodeBlocks
12
+ */
13
+
14
+ import { describe, it, expect } from 'bun:test'
15
+ import {
16
+ optimizeMarkdownForFeishu,
17
+ sanitizeTextForCard,
18
+ findMarkdownTablesOutsideCodeBlocks,
19
+ FEISHU_CARD_TABLE_LIMIT,
20
+ } from '../markdown-style.js'
21
+
22
+ // 默认 cardVersion=2 的 shortcut
23
+ const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v)
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // 标题降级
27
+ // ---------------------------------------------------------------------------
28
+
29
+ describe('optimizeMarkdownForFeishu: 标题降级', () => {
30
+ it('H1 → H4 (cardVersion=1 简化检查)', () => {
31
+ expect(opt('# Title', 1)).toBe('#### Title')
32
+ })
33
+
34
+ it('H2 → H5 (cardVersion=1)', () => {
35
+ expect(opt('## Title', 1)).toBe('##### Title')
36
+ })
37
+
38
+ it('H3 → H5 (cardVersion=1)', () => {
39
+ expect(opt('### Title', 1)).toBe('##### Title')
40
+ })
41
+
42
+ it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => {
43
+ expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3')
44
+ })
45
+
46
+ it('纯 H4 文档不触发降级 (cardVersion=1)', () => {
47
+ // 触发条件: 原文必须有 H1~H3
48
+ expect(opt('#### Already H4', 1)).toBe('#### Already H4')
49
+ })
50
+
51
+ it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => {
52
+ expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub')
53
+ })
54
+
55
+ it('# 后必须有空格才算标题', () => {
56
+ expect(opt('#notaheading', 1)).toBe('#notaheading')
57
+ })
58
+
59
+ it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => {
60
+ // openclaw-lark 源码里的关键注释:顺序不能颠倒
61
+ expect(opt('# Top', 1)).toBe('#### Top')
62
+ })
63
+
64
+ it('无标题文本原样返回', () => {
65
+ expect(opt('just plain text', 1)).toBe('just plain text')
66
+ })
67
+
68
+ it('默认 cardVersion=2 下也能正确降级标题', () => {
69
+ const out = opt('# Title')
70
+ expect(out).toContain('#### Title')
71
+ expect(out).not.toMatch(/^# Title$/m)
72
+ })
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // 代码块保护
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('optimizeMarkdownForFeishu: 代码块保护', () => {
80
+ it('代码块内的 # 不被降级 (cardVersion=1)', () => {
81
+ const input = '```\n# not a heading\n## also not\n```'
82
+ expect(opt(input, 1)).toBe(input)
83
+ })
84
+
85
+ it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => {
86
+ const input = '# Real heading\n\n```\n# inside code\n```'
87
+ expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```')
88
+ })
89
+
90
+ it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => {
91
+ const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
92
+ expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
93
+ })
94
+
95
+ it('多个代码块按顺序保护与还原 (cardVersion=1)', () => {
96
+ const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
97
+ expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
98
+ })
99
+
100
+ it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => {
101
+ const out = opt('# Heading\n\n```\n# inside\n```')
102
+ expect(out).toContain('#### Heading') // 外部 H1 降级
103
+ expect(out).toContain('# inside') // 代码块内保留
104
+ expect(out).toContain('```') // fence 保留
105
+ })
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 空行压缩
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('optimizeMarkdownForFeishu: 空行压缩', () => {
113
+ it('3 个换行 → 2 个 (cardVersion=1)', () => {
114
+ expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2')
115
+ })
116
+
117
+ it('5 个换行 → 2 个 (cardVersion=1)', () => {
118
+ expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2')
119
+ })
120
+
121
+ it('2 个换行保留 (cardVersion=1)', () => {
122
+ expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2')
123
+ })
124
+
125
+ it('代码块内部连续换行被保留 (cardVersion=1)', () => {
126
+ const input = '# Title\n\n```\nline1\n\n\nline2\n```'
127
+ expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
128
+ })
129
+ })
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Schema 2.0: <br> 间距
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
136
+ it('cardVersion=2 默认在代码块前后加 <br>', () => {
137
+ const out = opt('text\n\n```\ncode\n```')
138
+ // 代码块前后应包裹 <br>
139
+ expect(out).toContain('<br>\n```')
140
+ expect(out).toContain('```\n<br>')
141
+ })
142
+
143
+ it('cardVersion=1 代码块前后不加 <br>', () => {
144
+ const out = opt('text\n\n```\ncode\n```', 1)
145
+ expect(out).not.toContain('<br>')
146
+ })
147
+
148
+ it('cardVersion=2 连续标题之间加 <br>', () => {
149
+ const out = opt('# A\n# B')
150
+ // H1 降级为 H4,之间插入 <br>
151
+ expect(out).toMatch(/#### A\n<br>\n#### B/)
152
+ })
153
+
154
+ it('cardVersion=2 表格前后加 <br>', () => {
155
+ const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
156
+ const out = opt(input)
157
+ // 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
158
+ expect(out).toMatch(/text before\n<br>\n\| col1/)
159
+ // 表格后: <br> 跟两个换行到下一段文本
160
+ expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
161
+ })
162
+
163
+ it('cardVersion=1 表格前后不加 <br>', () => {
164
+ const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
165
+ const out = opt(input, 1)
166
+ expect(out).not.toContain('<br>')
167
+ })
168
+
169
+ it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => {
170
+ const input = '```\n| in code | not a table |\n|---|---|\n```'
171
+ const out = opt(input)
172
+ // 代码块本体应完整保留
173
+ expect(out).toContain('| in code | not a table |')
174
+ // 代码块外应该没有出现表格 br 标记(因为代码块内不算表格)
175
+ // 代码块本身会被 <br> 包裹(Schema 2.0)但不会在 | 周围单独加 <br>
176
+ expect(out).toMatch(/<br>\n```\n\| in code/)
177
+ })
178
+ })
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // stripInvalidImageKeys
182
+ // ---------------------------------------------------------------------------
183
+
184
+ describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => {
185
+ it('img_* 图片 key 保留', () => {
186
+ const out = opt('前缀 ![alt](img_abc123) 后缀')
187
+ expect(out).toContain('![alt](img_abc123)')
188
+ })
189
+
190
+ it('http:// URL 图片被删除', () => {
191
+ const out = opt('前缀 ![alt](http://example.com/img.png) 后缀')
192
+ expect(out).toBe('前缀 后缀')
193
+ })
194
+
195
+ it('https:// URL 图片被删除', () => {
196
+ const out = opt('![a](https://x.y/z.jpg)')
197
+ expect(out).toBe('')
198
+ })
199
+
200
+ it('本地路径被删除', () => {
201
+ const out = opt('![a](/Users/me/pic.png)')
202
+ expect(out).toBe('')
203
+ })
204
+
205
+ it('无图片文本原样', () => {
206
+ expect(opt('no images here', 1)).toBe('no images here')
207
+ })
208
+
209
+ it('混合: img_ 保留,URL 删除', () => {
210
+ const out = opt('![keep](img_good) 和 ![drop](http://bad.com/x.png)')
211
+ expect(out).toContain('![keep](img_good)')
212
+ expect(out).not.toContain('bad.com')
213
+ expect(out).not.toContain('![drop]')
214
+ })
215
+ })
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // findMarkdownTablesOutsideCodeBlocks
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('findMarkdownTablesOutsideCodeBlocks', () => {
222
+ it('识别单张表格', () => {
223
+ const text = '| a | b |\n|---|---|\n| 1 | 2 |'
224
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
225
+ expect(matches.length).toBe(1)
226
+ expect(matches[0]!.raw).toContain('| a | b |')
227
+ })
228
+
229
+ it('识别多张表格', () => {
230
+ const text =
231
+ '| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |'
232
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
233
+ expect(matches.length).toBe(2)
234
+ })
235
+
236
+ it('代码块内的 | 不被算作表格', () => {
237
+ const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```'
238
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
239
+ expect(matches.length).toBe(0)
240
+ })
241
+
242
+ it('代码块 + 外部表格: 只识别外部的', () => {
243
+ const text =
244
+ '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |'
245
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
246
+ expect(matches.length).toBe(1)
247
+ expect(matches[0]!.raw).toContain('real')
248
+ })
249
+
250
+ it('无表格文本返回空数组', () => {
251
+ expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0)
252
+ })
253
+ })
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // sanitizeTextForCard
257
+ // ---------------------------------------------------------------------------
258
+
259
+ describe('sanitizeTextForCard: 表格数量限制', () => {
260
+ function makeTable(label: string): string {
261
+ return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |`
262
+ }
263
+
264
+ it('表格数 ≤ 3 时原样返回', () => {
265
+ const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
266
+ expect(sanitizeTextForCard(text)).toBe(text)
267
+ })
268
+
269
+ it('恰好 3 张表格原样返回', () => {
270
+ const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
271
+ const matches = findMarkdownTablesOutsideCodeBlocks(text)
272
+ expect(matches.length).toBe(3)
273
+ expect(sanitizeTextForCard(text)).toBe(text)
274
+ })
275
+
276
+ it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => {
277
+ const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n')
278
+ const out = sanitizeTextForCard(text)
279
+ // 前 3 张表格原样
280
+ expect(out).toContain(makeTable('A'))
281
+ expect(out).toContain(makeTable('B'))
282
+ expect(out).toContain(makeTable('C'))
283
+ // 第 4 张被包裹
284
+ expect(out).toContain('```\n' + makeTable('D') + '\n```')
285
+ })
286
+
287
+ it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => {
288
+ const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
289
+ const out = sanitizeTextForCard(text, 1)
290
+ expect(out).toContain(makeTable('A'))
291
+ expect(out).toContain('```\n' + makeTable('B') + '\n```')
292
+ expect(out).toContain('```\n' + makeTable('C') + '\n```')
293
+ })
294
+
295
+ it('limit=0: 全部包裹', () => {
296
+ const text = makeTable('Solo')
297
+ const out = sanitizeTextForCard(text, 0)
298
+ expect(out).toContain('```\n' + makeTable('Solo') + '\n```')
299
+ })
300
+
301
+ it('无表格原样返回', () => {
302
+ expect(sanitizeTextForCard('no tables here')).toBe('no tables here')
303
+ })
304
+
305
+ it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => {
306
+ expect(FEISHU_CARD_TABLE_LIMIT).toBe(3)
307
+ })
308
+ })
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // 边界与真实场景
312
+ // ---------------------------------------------------------------------------
313
+
314
+ describe('optimizeMarkdownForFeishu: 边界与真实场景', () => {
315
+ it('screenshot 里的 OpenCutSkill 项目结构报告', () => {
316
+ const input = `## OpenCutSkill 项目架构概览
317
+
318
+ ### 1. 项目定位
319
+
320
+ Screen Studio 视频自动剪辑工具。
321
+
322
+ ### 2. 模块结构
323
+
324
+ \`\`\`
325
+ opencutskill/
326
+ ├── cli/
327
+ ├── core/
328
+ └── tests/
329
+ \`\`\``
330
+ const out = opt(input)
331
+ // 所有 H2~H3 应被降级为 H5
332
+ expect(out).toContain('##### OpenCutSkill 项目架构概览')
333
+ expect(out).toContain('##### 1. 项目定位')
334
+ expect(out).toContain('##### 2. 模块结构')
335
+ // 代码块内容原封不动
336
+ expect(out).toContain('opencutskill/')
337
+ expect(out).toContain('├── cli/')
338
+ // 原始 ## 字面量不残留
339
+ expect(out).not.toMatch(/^## OpenCutSkill/m)
340
+ expect(out).not.toMatch(/^### 1\./m)
341
+ // 代码块前后有 <br>(Schema 2.0 默认)
342
+ expect(out).toContain('<br>\n```')
343
+ expect(out).toContain('```\n<br>')
344
+ })
345
+
346
+ it('异常输入 fallback 到原文不抛错', () => {
347
+ expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow()
348
+ })
349
+
350
+ it('空字符串返回空字符串', () => {
351
+ expect(opt('')).toBe('')
352
+ })
353
+ })
adapters/feishu/__tests__/media.test.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { FeishuMediaService } from '../media.js'
6
+ import { AttachmentStore } from '../../common/attachment/attachment-store.js'
7
+
8
+ function makeMockClient() {
9
+ return {
10
+ im: {
11
+ messageResource: {
12
+ get: mock(async () => ({
13
+ // node-sdk returns an object with a `.writeFile(path)` helper
14
+ // that dumps the underlying stream. We fake that here.
15
+ writeFile: async (target: string) => {
16
+ await fs.writeFile(target, Buffer.from('DOWNLOADED'))
17
+ },
18
+ })),
19
+ },
20
+ image: {
21
+ create: mock(async (_req: any) => ({
22
+ data: { image_key: 'img_fake_123' },
23
+ })),
24
+ },
25
+ file: {
26
+ create: mock(async (_req: any) => ({
27
+ data: { file_key: 'file_fake_456' },
28
+ })),
29
+ },
30
+ message: {
31
+ create: mock(async (_req: any) => ({
32
+ data: { message_id: 'om_fake' },
33
+ })),
34
+ },
35
+ },
36
+ }
37
+ }
38
+
39
+ let tmpRoot: string
40
+
41
+ beforeEach(async () => {
42
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-'))
43
+ })
44
+
45
+ afterEach(async () => {
46
+ await fs.rm(tmpRoot, { recursive: true, force: true })
47
+ })
48
+
49
+ describe('FeishuMediaService', () => {
50
+ it('downloadResource writes a local file and returns LocalAttachment', async () => {
51
+ const client = makeMockClient()
52
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
53
+ const svc = new FeishuMediaService(client as any, store)
54
+ const local = await svc.downloadResource({
55
+ messageId: 'om_msg_1',
56
+ fileKey: 'img_key_1',
57
+ kind: 'image',
58
+ fileName: 'cat.png',
59
+ sessionId: 'sess-1',
60
+ })
61
+ expect(local.kind).toBe('image')
62
+ expect(local.name).toBe('cat.png')
63
+ expect(local.size).toBe('DOWNLOADED'.length)
64
+ expect(local.path).toContain(path.join('feishu', 'sess-1'))
65
+ const onDisk = await fs.readFile(local.path)
66
+ expect(onDisk.toString()).toBe('DOWNLOADED')
67
+ expect(client.im.messageResource.get).toHaveBeenCalledTimes(1)
68
+ const call = (client.im.messageResource.get as any).mock.calls[0][0]
69
+ expect(call.path.message_id).toBe('om_msg_1')
70
+ expect(call.path.file_key).toBe('img_key_1')
71
+ expect(call.params.type).toBe('image')
72
+ })
73
+
74
+ it('uploadImage returns an image_key and sends the buffer through', async () => {
75
+ const client = makeMockClient()
76
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
77
+ const svc = new FeishuMediaService(client as any, store)
78
+ const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png')
79
+ expect(key).toBe('img_fake_123')
80
+ expect(client.im.image.create).toHaveBeenCalledTimes(1)
81
+ const call = (client.im.image.create as any).mock.calls[0][0]
82
+ expect(call.data.image_type).toBe('message')
83
+ expect(call.data.image).toBeDefined()
84
+ })
85
+
86
+ it('uploadFile returns a file_key and uses stream file_type mapping', async () => {
87
+ const client = makeMockClient()
88
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
89
+ const svc = new FeishuMediaService(client as any, store)
90
+ const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf')
91
+ expect(key).toBe('file_fake_456')
92
+ const call = (client.im.file.create as any).mock.calls[0][0]
93
+ expect(call.data.file_name).toBe('report.pdf')
94
+ expect(call.data.file_type).toBe('pdf')
95
+ })
96
+
97
+ it('sendImageMessage posts msg_type=image', async () => {
98
+ const client = makeMockClient()
99
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
100
+ const svc = new FeishuMediaService(client as any, store)
101
+ await svc.sendImageMessage('oc_chat_1', 'img_fake_123')
102
+ const call = (client.im.message.create as any).mock.calls[0][0]
103
+ expect(call.params.receive_id_type).toBe('chat_id')
104
+ expect(call.data.receive_id).toBe('oc_chat_1')
105
+ expect(call.data.msg_type).toBe('image')
106
+ const content = JSON.parse(call.data.content)
107
+ expect(content.image_key).toBe('img_fake_123')
108
+ })
109
+
110
+ it('sendFileMessage posts msg_type=file', async () => {
111
+ const client = makeMockClient()
112
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
113
+ const svc = new FeishuMediaService(client as any, store)
114
+ await svc.sendFileMessage('oc_chat_1', 'file_fake_456')
115
+ const call = (client.im.message.create as any).mock.calls[0][0]
116
+ expect(call.data.msg_type).toBe('file')
117
+ const content = JSON.parse(call.data.content)
118
+ expect(content.file_key).toBe('file_fake_456')
119
+ })
120
+ })
adapters/feishu/__tests__/streaming-card.test.ts ADDED
@@ -0,0 +1,947 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StreamingCard 生命周期测试
3
+ *
4
+ * 用 mock Lark client 覆盖:
5
+ * - ensureCreated: 成功路径 / 降级路径
6
+ * - appendText: 累积 + 触发 throttled flush
7
+ * - finalize: settings(false) + update 顺序、sequence 单调递增
8
+ * - abort: 渲染错误卡片
9
+ * - 230020 → 跳帧
10
+ * - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
11
+ * - 纯 patch fallback 路径
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'bun:test'
15
+ import {
16
+ StreamingCard,
17
+ buildInitialStreamingCard,
18
+ buildRenderedCard,
19
+ buildErrorCard,
20
+ } from '../streaming-card.js'
21
+ import { STREAMING_ELEMENT_ID } from '../cardkit.js'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Mock client
25
+ // ---------------------------------------------------------------------------
26
+
27
+ type ApiCall = { api: string; args: any }
28
+
29
+ type MockBehavior = {
30
+ 'card.create'?: any | ((args: any) => any)
31
+ 'card.settings'?: any | ((args: any) => any)
32
+ 'card.update'?: any | ((args: any) => any)
33
+ 'cardElement.content'?: any | ((args: any, callIdx: number) => any)
34
+ 'im.message.create'?: any | ((args: any) => any)
35
+ 'im.message.reply'?: any | ((args: any) => any)
36
+ 'im.message.patch'?: any | ((args: any, callIdx: number) => any)
37
+ }
38
+
39
+ function makeMockClient(behavior: MockBehavior = {}) {
40
+ const calls: ApiCall[] = []
41
+ let contentCallIdx = 0
42
+ let patchCallIdx = 0
43
+
44
+ function handle(api: string, resp: any, args: any, idx?: number): any {
45
+ calls.push({ api, args })
46
+ if (typeof resp === 'function') return resp(args, idx ?? 0)
47
+ return resp
48
+ }
49
+
50
+ const client: any = {
51
+ cardkit: {
52
+ v1: {
53
+ card: {
54
+ create: async (args: any) =>
55
+ handle('cardkit.v1.card.create', behavior['card.create'] ?? {
56
+ code: 0, data: { card_id: 'ck_default' },
57
+ }, args),
58
+ settings: async (args: any) =>
59
+ handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
60
+ update: async (args: any) =>
61
+ handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
62
+ },
63
+ cardElement: {
64
+ content: async (args: any) => {
65
+ const idx = contentCallIdx++
66
+ return handle('cardkit.v1.cardElement.content',
67
+ behavior['cardElement.content'] ?? { code: 0 }, args, idx)
68
+ },
69
+ },
70
+ },
71
+ },
72
+ im: {
73
+ message: {
74
+ create: async (args: any) =>
75
+ handle('im.message.create', behavior['im.message.create'] ?? {
76
+ data: { message_id: 'om_default' },
77
+ }, args),
78
+ reply: async (args: any) =>
79
+ handle('im.message.reply', behavior['im.message.reply'] ?? {
80
+ data: { message_id: 'om_reply_default' },
81
+ }, args),
82
+ patch: async (args: any) => {
83
+ const idx = patchCallIdx++
84
+ return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
85
+ },
86
+ },
87
+ },
88
+ }
89
+ return { client, calls }
90
+ }
91
+
92
+ async function sleep(ms: number) {
93
+ await new Promise((r) => setTimeout(r, ms))
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Card JSON builders
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('buildInitialStreamingCard', () => {
101
+ it('Schema 2.0 + streaming_mode + element_id', () => {
102
+ const card = buildInitialStreamingCard() as any
103
+ expect(card.schema).toBe('2.0')
104
+ expect(card.config.streaming_mode).toBe(true)
105
+ // 唯一元素:streaming_content,初始内容为 loading 提示
106
+ const elements = card.body.elements as any[]
107
+ expect(elements.length).toBe(1)
108
+ const streaming = elements[0]
109
+ expect(streaming.tag).toBe('markdown')
110
+ expect(streaming.content).toContain('正在思考中')
111
+ expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
112
+ })
113
+ })
114
+
115
+ describe('buildRenderedCard', () => {
116
+ it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
117
+ const card = buildRenderedCard('hello world') as any
118
+ expect(card.schema).toBe('2.0')
119
+ expect(card.config.streaming_mode).toBeUndefined()
120
+ expect(card.body.elements.length).toBe(1)
121
+ const el = card.body.elements[0]
122
+ expect(el.tag).toBe('markdown')
123
+ expect(el.content).toBe('hello world')
124
+ // 最终卡无需 element_id
125
+ expect(el.element_id).toBeUndefined()
126
+ })
127
+
128
+ it('空字符串保底为单空格', () => {
129
+ const card = buildRenderedCard('') as any
130
+ expect(card.body.elements[0].content).toBe(' ')
131
+ })
132
+ })
133
+
134
+ describe('buildErrorCard', () => {
135
+ it('红色 header + markdown body', () => {
136
+ const card = buildErrorCard('oops') as any
137
+ expect((card.header as any).template).toBe('red')
138
+ expect((card.header as any).title.content).toContain('出错')
139
+ expect(card.body.elements[0].content).toBe('oops')
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // StreamingCard lifecycle
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
148
+ it('依次调用 card.create + im.message.create,sequence=1', async () => {
149
+ const { client, calls } = makeMockClient({
150
+ 'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
151
+ 'im.message.create': { data: { message_id: 'om_main_1' } },
152
+ })
153
+ const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
154
+ await sc.ensureCreated()
155
+
156
+ expect(sc._getPhase()).toBe('streaming')
157
+ expect(sc._getCardId()).toBe('ck_main_1')
158
+ expect(sc._getMessageId()).toBe('om_main_1')
159
+ expect(sc._getSequence()).toBe(1)
160
+ expect(sc._isCardKitStreamActive()).toBe(true)
161
+
162
+ expect(calls[0]!.api).toBe('cardkit.v1.card.create')
163
+ expect(calls[1]!.api).toBe('im.message.create')
164
+
165
+ // 初始卡 JSON 包含 streaming_mode 和 element_id
166
+ const cardJson = JSON.parse(calls[0]!.args.data.data)
167
+ expect(cardJson.schema).toBe('2.0')
168
+ expect(cardJson.config.streaming_mode).toBe(true)
169
+ // 唯一元素即 streaming_content
170
+ expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
171
+
172
+ // IM message 引用 card_id
173
+ const content = JSON.parse(calls[1]!.args.data.content)
174
+ expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
175
+ })
176
+
177
+ it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
178
+ const { client, calls } = makeMockClient({
179
+ 'card.create': { code: 0, data: { card_id: 'ck_1' } },
180
+ 'im.message.create': { data: { message_id: 'om_1' } },
181
+ })
182
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
183
+ await sc.ensureCreated()
184
+ await sc.ensureCreated()
185
+ await sc.ensureCreated()
186
+ // 只一次 create + 一次 send
187
+ const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
188
+ const sendCalls = calls.filter((c) => c.api === 'im.message.create')
189
+ expect(createCalls.length).toBe(1)
190
+ expect(sendCalls.length).toBe(1)
191
+ })
192
+
193
+ it('replyToMessageId 走 im.message.reply 而非 create', async () => {
194
+ const { client, calls } = makeMockClient({
195
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
196
+ 'im.message.reply': { data: { message_id: 'om_reply' } },
197
+ })
198
+ const sc = new StreamingCard({
199
+ larkClient: client,
200
+ chatId: 'c',
201
+ replyToMessageId: 'om_parent',
202
+ })
203
+ await sc.ensureCreated()
204
+ expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
205
+ expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
206
+ expect(sc._getMessageId()).toBe('om_reply')
207
+ })
208
+ })
209
+
210
+ describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
211
+ it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
212
+ const { client, calls } = makeMockClient({
213
+ 'card.create': { code: 99991672, msg: 'permission denied' },
214
+ 'im.message.create': { data: { message_id: 'om_fb' } },
215
+ })
216
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
217
+ await sc.ensureCreated()
218
+
219
+ expect(sc._getPhase()).toBe('streaming')
220
+ expect(sc._getCardId()).toBeNull()
221
+ expect(sc._getMessageId()).toBe('om_fb')
222
+ expect(sc._isCardKitStreamActive()).toBe(false)
223
+
224
+ // fallback 发送的是 Schema 2.0 interactive 卡
225
+ const createCall = calls.find((c) => c.api === 'im.message.create')
226
+ expect(createCall).toBeDefined()
227
+ expect(createCall!.args.data.msg_type).toBe('interactive')
228
+ const cardContent = JSON.parse(createCall!.args.data.content)
229
+ expect(cardContent.schema).toBe('2.0')
230
+ })
231
+
232
+ it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
233
+ let sendCallCount = 0
234
+ const { client } = makeMockClient({
235
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
236
+ 'im.message.create': () => {
237
+ sendCallCount++
238
+ if (sendCallCount === 1) throw new Error('send failed')
239
+ return { data: { message_id: 'om_fb2' } }
240
+ },
241
+ })
242
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
243
+ await sc.ensureCreated()
244
+ expect(sc._getPhase()).toBe('streaming')
245
+ expect(sc._getCardId()).toBeNull()
246
+ expect(sc._getMessageId()).toBe('om_fb2')
247
+ })
248
+
249
+ it('降级发送也失败 → aborted + throw', async () => {
250
+ const { client } = makeMockClient({
251
+ 'card.create': { code: 99991672 },
252
+ 'im.message.create': () => {
253
+ throw new Error('really broken')
254
+ },
255
+ })
256
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
257
+ await expect(sc.ensureCreated()).rejects.toThrow()
258
+ expect(sc._getPhase()).toBe('aborted')
259
+ })
260
+ })
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // appendText + flush
264
+ // ---------------------------------------------------------------------------
265
+
266
+ describe('StreamingCard: appendText + flush', () => {
267
+ it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
268
+ const { client, calls } = makeMockClient({
269
+ 'card.create': { code: 0, data: { card_id: 'ck_stream' } },
270
+ 'im.message.create': { data: { message_id: 'om' } },
271
+ })
272
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
273
+ await sc.ensureCreated()
274
+
275
+ // 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
276
+ sc.appendText('Hello ')
277
+ sc.appendText('world')
278
+
279
+ // 节流窗口 100ms + 余量
280
+ await sleep(150)
281
+
282
+ const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
283
+ expect(contentCalls.length).toBeGreaterThan(0)
284
+ // 最后一次 flush 的内容应包含完整累积文本
285
+ const lastCall = contentCalls[contentCalls.length - 1]!
286
+ expect(lastCall.args.data.content).toContain('Hello world')
287
+ expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
288
+ // sequence 严格单调递增
289
+ const seqs = contentCalls.map((c) => c.args.data.sequence)
290
+ for (let i = 1; i < seqs.length; i++) {
291
+ expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
292
+ }
293
+ })
294
+
295
+ it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
296
+ const { client, calls } = makeMockClient({
297
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
298
+ 'im.message.create': { data: { message_id: 'om' } },
299
+ })
300
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
301
+ await sc.ensureCreated()
302
+
303
+ sc.appendText('same')
304
+ await sleep(150)
305
+
306
+ // 强制再跑一次 flush(无新文本)
307
+ await sc._getFlushController().flush()
308
+
309
+ const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
310
+ // 应该只有一次 content 调用
311
+ expect(contentCalls.length).toBe(1)
312
+ })
313
+
314
+ it('completed 之后的 appendText 被忽略', async () => {
315
+ const { client } = makeMockClient({
316
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
317
+ 'im.message.create': { data: { message_id: 'om' } },
318
+ })
319
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
320
+ await sc.ensureCreated()
321
+ await sc.finalize()
322
+ sc.appendText('ignored')
323
+ expect(sc._getAccumulatedText()).toBe('')
324
+ })
325
+ })
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // finalize
329
+ // ---------------------------------------------------------------------------
330
+
331
+ describe('StreamingCard: finalize', () => {
332
+ it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
333
+ const { client, calls } = makeMockClient({
334
+ 'card.create': { code: 0, data: { card_id: 'ck_final' } },
335
+ 'im.message.create': { data: { message_id: 'om' } },
336
+ })
337
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
338
+ await sc.ensureCreated()
339
+ sc.appendText('# Title\n\nBody')
340
+ await sleep(150)
341
+ const contentSeqs = calls
342
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
343
+ .map((c) => c.args.data.sequence)
344
+ const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
345
+
346
+ await sc.finalize()
347
+
348
+ expect(sc._getPhase()).toBe('completed')
349
+
350
+ const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
351
+ const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
352
+ expect(settingsCalls.length).toBe(1)
353
+ expect(updateCalls.length).toBe(1)
354
+
355
+ const settingsSeq = settingsCalls[0]!.args.data.sequence
356
+ const updateSeq = updateCalls[0]!.args.data.sequence
357
+ expect(settingsSeq).toBeGreaterThan(lastContentSeq)
358
+ expect(updateSeq).toBeGreaterThan(settingsSeq)
359
+
360
+ // settings 关闭 streaming_mode
361
+ const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
362
+ expect(settings.streaming_mode).toBe(false)
363
+
364
+ // update 卡内容是预处理后的 markdown
365
+ const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
366
+ const finalContent = finalCardJson.body.elements[0].content
367
+ // H1 被降级为 H4
368
+ expect(finalContent).toContain('#### Title')
369
+ expect(finalContent).toContain('Body')
370
+ })
371
+
372
+ it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
373
+ const { client, calls } = makeMockClient({
374
+ 'card.create': { code: 99991672 },
375
+ 'im.message.create': { data: { message_id: 'om_fb' } },
376
+ })
377
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
378
+ await sc.ensureCreated()
379
+ sc.appendText('## Heading\n\nContent')
380
+ await sleep(1600) // 等 PATCH_MS 窗口
381
+ await sc.finalize()
382
+
383
+ const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
384
+ expect(patchCalls.length).toBeGreaterThan(0)
385
+ // 最后一次 patch 是 finalize 的(full final card)
386
+ const lastPatch = patchCalls[patchCalls.length - 1]!
387
+ const finalCard = JSON.parse(lastPatch.args.data.content)
388
+ const finalContent = finalCard.body.elements[0].content
389
+ // ## → ##### 降级
390
+ expect(finalContent).toContain('##### Heading')
391
+ })
392
+
393
+ it('完全 idle 时 finalize ��接标记 completed 不抛错', async () => {
394
+ const { client } = makeMockClient()
395
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
396
+ await sc.finalize()
397
+ expect(sc._getPhase()).toBe('completed')
398
+ })
399
+
400
+ it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => {
401
+ const { client, calls } = makeMockClient({
402
+ 'card.create': { code: 0, data: { card_id: 'ck_term' } },
403
+ 'im.message.create': { data: { message_id: 'om' } },
404
+ })
405
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
406
+ await sc.ensureCreated()
407
+
408
+ // 同时塞入三种内容
409
+ sc.appendReasoning('Let me think about this problem carefully...')
410
+ sc.startTool('tu_1', 'Read')
411
+ sc.completeTool('tu_1', 'Read')
412
+ sc.appendText('## 答复\n\n这是最终答复正文。')
413
+ await sleep(150)
414
+
415
+ // 流式中间帧应该包含 reasoning + tools + answer 全套
416
+ const lastMidFrame = calls
417
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
418
+ .pop()!.args.data.content as string
419
+ expect(lastMidFrame).toContain('思考中')
420
+ expect(lastMidFrame).toContain('Read')
421
+ expect(lastMidFrame).toContain('最终答复正文')
422
+
423
+ await sc.finalize()
424
+
425
+ // finalize 用的是 card.update,把整张卡换成只有 answer 的版本
426
+ const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
427
+ const finalCardJson = JSON.parse(updateCall.args.data.card.data)
428
+ const finalContent = finalCardJson.body.elements[0].content as string
429
+
430
+ expect(finalContent).toContain('最终答复正文')
431
+ // H2 → 降级 H5
432
+ expect(finalContent).toContain('##### 答复')
433
+ // reasoning + tools 都不应该出现在终态
434
+ expect(finalContent).not.toContain('思考中')
435
+ expect(finalContent).not.toContain('think about this problem')
436
+ expect(finalContent).not.toContain('Read')
437
+ expect(finalContent).not.toContain('🛠️')
438
+ expect(finalContent).not.toContain('💭')
439
+ })
440
+
441
+ it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
442
+ const { client, calls } = makeMockClient({
443
+ 'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
444
+ 'im.message.create': { data: { message_id: 'om' } },
445
+ })
446
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
447
+ await sc.ensureCreated()
448
+
449
+ // 只有推理,没有 appendText —— 异常 case 但要可控降级
450
+ sc.appendReasoning('I was thinking but never produced an answer.')
451
+ await sleep(150)
452
+
453
+ await sc.finalize()
454
+ const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
455
+ const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
456
+ // 至少能看到推理内容
457
+ expect(finalContent).toContain('thinking')
458
+ })
459
+
460
+ it('finalize 失败不抛出', async () => {
461
+ const { client } = makeMockClient({
462
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
463
+ 'im.message.create': { data: { message_id: 'om' } },
464
+ 'card.settings': () => {
465
+ throw new Error('settings exploded')
466
+ },
467
+ })
468
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
469
+ await sc.ensureCreated()
470
+ sc.appendText('text')
471
+ await sleep(150)
472
+ // finalize 内部捕获错误不 rethrow
473
+ await sc.finalize()
474
+ expect(sc._getPhase()).toBe('completed')
475
+ })
476
+ })
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Rate limit + table limit
480
+ // ---------------------------------------------------------------------------
481
+
482
+ describe('StreamingCard: 错误处理', () => {
483
+ it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
484
+ let callIdx = 0
485
+ const { client, calls } = makeMockClient({
486
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
487
+ 'im.message.create': { data: { message_id: 'om' } },
488
+ 'cardElement.content': () => {
489
+ const i = callIdx++
490
+ if (i === 0) {
491
+ const err: any = new Error('rate limit')
492
+ err.code = 230020
493
+ throw err
494
+ }
495
+ return { code: 0 }
496
+ },
497
+ })
498
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
499
+ await sc.ensureCreated()
500
+ sc.appendText('first')
501
+ await sleep(150)
502
+ // 第一次被限流
503
+ sc.appendText(' second')
504
+ await sleep(150)
505
+ // 第二次应能成功
506
+
507
+ // CardKit 仍然 active(没降级)
508
+ expect(sc._isCardKitStreamActive()).toBe(true)
509
+ const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
510
+ expect(contentCalls.length).toBeGreaterThanOrEqual(2)
511
+ })
512
+
513
+ it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
514
+ const { client, calls } = makeMockClient({
515
+ 'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
516
+ 'im.message.create': { data: { message_id: 'om' } },
517
+ 'cardElement.content': () => {
518
+ const err: any = new Error('content failed')
519
+ err.code = 230099
520
+ err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
521
+ throw err
522
+ },
523
+ })
524
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
525
+ await sc.ensureCreated()
526
+ sc.appendText('some content')
527
+ await sleep(150)
528
+ expect(sc._isCardKitStreamActive()).toBe(false)
529
+ expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
530
+
531
+ await sc.finalize()
532
+ // finalize 仍然走 CardKit 的 settings + update(cardId 还在)
533
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
534
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
535
+ // 不走 patch
536
+ expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
537
+ })
538
+
539
+ it('CardKit 中间帧请求挂住时不会阻塞 message_complete 收尾', async () => {
540
+ const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
541
+ process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20'
542
+ try {
543
+ const { client, calls } = makeMockClient({
544
+ 'card.create': { code: 0, data: { card_id: 'ck_hung' } },
545
+ 'im.message.create': { data: { message_id: 'om' } },
546
+ 'cardElement.content': () => new Promise(() => {}),
547
+ })
548
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
549
+ await sc.ensureCreated()
550
+
551
+ sc.appendText('partial text')
552
+ await sleep(60)
553
+
554
+ const completed = await Promise.race([
555
+ sc.finalize().then(() => true),
556
+ sleep(250).then(() => false),
557
+ ])
558
+
559
+ expect(completed).toBe(true)
560
+ expect(sc._getPhase()).toBe('completed')
561
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
562
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
563
+ } finally {
564
+ if (previousTimeout === undefined) {
565
+ delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
566
+ } else {
567
+ process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout
568
+ }
569
+ }
570
+ })
571
+ })
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // abort
575
+ // ---------------------------------------------------------------------------
576
+
577
+ describe('StreamingCard: abort', () => {
578
+ it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
579
+ const { client, calls } = makeMockClient({
580
+ 'card.create': { code: 0, data: { card_id: 'ck_err' } },
581
+ 'im.message.create': { data: { message_id: 'om' } },
582
+ })
583
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
584
+ await sc.ensureCreated()
585
+ sc.appendText('partial...')
586
+ await sleep(150)
587
+
588
+ await sc.abort(new Error('something went wrong'))
589
+ expect(sc._getPhase()).toBe('aborted')
590
+
591
+ const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
592
+ expect(updateCalls.length).toBeGreaterThan(0)
593
+ const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
594
+ expect(errCard.header.template).toBe('red')
595
+ expect(errCard.body.elements[0].content).toContain('something went wrong')
596
+ // 保留已累积的部分文本
597
+ expect(errCard.body.elements[0].content).toContain('partial...')
598
+ })
599
+
600
+ it('idle 阶段 abort 不抛错', async () => {
601
+ const { client } = makeMockClient()
602
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
603
+ await sc.abort(new Error('before any card'))
604
+ expect(sc._getPhase()).toBe('aborted')
605
+ })
606
+ })
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Reasoning / tool use rendering
610
+ // ---------------------------------------------------------------------------
611
+
612
+ describe('StreamingCard: appendReasoning', () => {
613
+ it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => {
614
+ const { client, calls } = makeMockClient({
615
+ 'card.create': { code: 0, data: { card_id: 'ck_think' } },
616
+ 'im.message.create': { data: { message_id: 'om' } },
617
+ })
618
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
619
+ await sc.ensureCreated()
620
+
621
+ sc.appendReasoning('Analyzing the problem. ')
622
+ sc.appendReasoning('Let me check file A.')
623
+ await sleep(150)
624
+
625
+ const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
626
+ expect(contentCalls.length).toBeGreaterThan(0)
627
+ const last = contentCalls[contentCalls.length - 1]!
628
+ expect(last.args.data.content).toContain('💭')
629
+ expect(last.args.data.content).toContain('思考中')
630
+ expect(last.args.data.content).toContain('Analyzing the problem.')
631
+ expect(last.args.data.content).toContain('Let me check file A.')
632
+ // 没有 blockquote `>` 前缀 —— 这是新格式的关键
633
+ expect(last.args.data.content).not.toContain('> Analyzing')
634
+ // 没有 appendText → 不应有普通正文
635
+ expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
636
+ expect(sc._getAccumulatedText()).toBe('')
637
+ })
638
+
639
+ it('completed 之后 appendReasoning 被忽略', async () => {
640
+ const { client } = makeMockClient({
641
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
642
+ 'im.message.create': { data: { message_id: 'om' } },
643
+ })
644
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
645
+ await sc.ensureCreated()
646
+ await sc.finalize()
647
+ sc.appendReasoning('too late')
648
+ expect(sc._getAccumulatedReasoning()).toBe('')
649
+ })
650
+ })
651
+
652
+ describe('StreamingCard: startTool / completeTool', () => {
653
+ it('startTool 压入 running 步骤,completeTool 翻到 done', async () => {
654
+ const { client, calls } = makeMockClient({
655
+ 'card.create': { code: 0, data: { card_id: 'ck_tool' } },
656
+ 'im.message.create': { data: { message_id: 'om' } },
657
+ })
658
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
659
+ await sc.ensureCreated()
660
+
661
+ sc.startTool('tu_1', 'Read')
662
+ await sleep(150)
663
+ let steps = sc._getToolSteps()
664
+ expect(steps.length).toBe(1)
665
+ expect(steps[0]!.name).toBe('Read')
666
+ expect(steps[0]!.status).toBe('running')
667
+
668
+ // 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式)
669
+ const runningContent = calls
670
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
671
+ .map((c) => c.args.data.content)
672
+ .join('\n')
673
+ expect(runningContent).toContain('⚙️')
674
+ expect(runningContent).toContain('Read')
675
+ expect(runningContent).toContain('🛠️')
676
+
677
+ sc.completeTool('tu_1', 'Read')
678
+ await sleep(150)
679
+ steps = sc._getToolSteps()
680
+ expect(steps[0]!.status).toBe('done')
681
+
682
+ // 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
683
+ const lastContent = calls
684
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
685
+ .pop()!.args.data.content as string
686
+ expect(lastContent).toContain('✅')
687
+ expect(lastContent).toContain('Read')
688
+ // 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
689
+ expect(lastContent).not.toContain('⚙️')
690
+ })
691
+
692
+ it('按 toolUseId 去重: 同一 id 不重复压入', async () => {
693
+ const { client } = makeMockClient({
694
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
695
+ 'im.message.create': { data: { message_id: 'om' } },
696
+ })
697
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
698
+ await sc.ensureCreated()
699
+
700
+ sc.startTool('tu_1', 'Read')
701
+ sc.startTool('tu_1', 'Read')
702
+ sc.startTool('tu_1', 'Read')
703
+ expect(sc._getToolSteps().length).toBe(1)
704
+ })
705
+
706
+ it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => {
707
+ const { client } = makeMockClient({
708
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
709
+ 'im.message.create': { data: { message_id: 'om' } },
710
+ })
711
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
712
+ await sc.ensureCreated()
713
+
714
+ sc.startTool(undefined, 'Read')
715
+ sc.startTool(undefined, 'Read')
716
+ // 合成 id 不同 → 两个独立步骤
717
+ expect(sc._getToolSteps().length).toBe(2)
718
+ })
719
+
720
+ it('completeTool 只匹配最近的 running 同名步骤', async () => {
721
+ const { client } = makeMockClient({
722
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
723
+ 'im.message.create': { data: { message_id: 'om' } },
724
+ })
725
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
726
+ await sc.ensureCreated()
727
+
728
+ sc.startTool('tu_1', 'Bash')
729
+ sc.startTool('tu_2', 'Bash')
730
+ sc.completeTool(undefined, 'Bash')
731
+ const steps = sc._getToolSteps()
732
+ // 更晚的 tu_2 被标记 done
733
+ expect(steps[0]!.status).toBe('running')
734
+ expect(steps[1]!.status).toBe('done')
735
+ })
736
+
737
+ it('空 toolName 忽略', async () => {
738
+ const { client } = makeMockClient({
739
+ 'card.create': { code: 0, data: { card_id: 'ck' } },
740
+ 'im.message.create': { data: { message_id: 'om' } },
741
+ })
742
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
743
+ await sc.ensureCreated()
744
+
745
+ sc.startTool('tu_1', undefined)
746
+ sc.startTool('tu_1', '')
747
+ expect(sc._getToolSteps().length).toBe(0)
748
+ })
749
+ })
750
+
751
+ // 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
752
+ // 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
753
+ // gate / 等任何东西吃掉)。
754
+ describe('StreamingCard: 真实事件流(用户场景回归)', () => {
755
+ it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
756
+ const { client, calls } = makeMockClient({
757
+ 'card.create': { code: 0, data: { card_id: 'ck_real' } },
758
+ 'im.message.create': { data: { message_id: 'om_real' } },
759
+ })
760
+ const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
761
+
762
+ // 1. 用户发消息 → handleMessage 预建卡(fire-and-forget)
763
+ const creating = sc.ensureCreated()
764
+ await creating // 等卡可写
765
+
766
+ // 2. 服务端: status streaming + content_start{text} (thinking block)
767
+ // feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op��
768
+ // (no direct call here — 等同于 no-op)
769
+
770
+ // 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式)
771
+ sc.appendReasoning('Analyzing the latest commits to find ')
772
+ await sleep(30)
773
+ sc.appendReasoning('breaking changes. Need to look at ')
774
+ await sleep(30)
775
+ sc.appendReasoning('the public API surface, the schema files, ')
776
+ await sleep(30)
777
+ sc.appendReasoning('and any removed exports. Let me check the ')
778
+ await sleep(30)
779
+ sc.appendReasoning('git log first.')
780
+
781
+ // 等节流窗口结束
782
+ await sleep(200)
783
+
784
+ const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
785
+ expect(flushesAfterReasoning).toBeGreaterThan(0)
786
+
787
+ const lastReasoningContent = calls
788
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
789
+ .pop()!.args.data.content as string
790
+ // 应该包含 reasoning 累积内容
791
+ expect(lastReasoningContent).toContain('breaking changes')
792
+ expect(lastReasoningContent).toContain('git log first')
793
+
794
+ // 4. 服务端: content_start{tool_use, name: 'Bash'}
795
+ sc.startTool('tu_bash_1', 'Bash')
796
+ await sleep(150)
797
+
798
+ const lastWithTool = calls
799
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
800
+ .pop()!.args.data.content as string
801
+ expect(lastWithTool).toContain('Bash')
802
+ expect(lastWithTool).toContain('⚙️')
803
+ expect(lastWithTool).toContain('🛠️')
804
+
805
+ // 5. 服务端: tool_use_complete
806
+ sc.completeTool('tu_bash_1', 'Bash')
807
+ await sleep(150)
808
+
809
+ const lastAfterToolDone = calls
810
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
811
+ .pop()!.args.data.content as string
812
+ expect(lastAfterToolDone).toContain('Bash')
813
+ // ⚙️ 切到 ✅ —— 当前唯一一步已完成
814
+ expect(lastAfterToolDone).toContain('✅')
815
+ expect(lastAfterToolDone).not.toContain('⚙️')
816
+
817
+ // 6. 第二个 tool 序列
818
+ sc.startTool('tu_read_1', 'Read')
819
+ await sleep(150)
820
+ sc.completeTool('tu_read_1', 'Read')
821
+ await sleep(150)
822
+
823
+ // 7. 最终 text 输出
824
+ sc.appendText('## 破坏性变更分析\n\n')
825
+ await sleep(120)
826
+ sc.appendText('1. **API 重命名**: foo → bar\n')
827
+ await sleep(120)
828
+ sc.appendText('2. **删除导出**: baz')
829
+ await sleep(200)
830
+
831
+ const lastWithText = calls
832
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
833
+ .pop()!.args.data.content as string
834
+ // 应该同时包含 reasoning, tools, answer
835
+ expect(lastWithText).toContain('git log first') // reasoning
836
+ expect(lastWithText).toContain('Bash') // tool
837
+ expect(lastWithText).toContain('Read') // tool
838
+ expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
839
+ expect(lastWithText).toContain('API 重命名')
840
+
841
+ // 8. message_complete → finalize
842
+ await sc.finalize()
843
+ expect(sc._getPhase()).toBe('completed')
844
+
845
+ // 验证有 settings + update 收尾
846
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
847
+ expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
848
+ })
849
+
850
+ it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
851
+ let firstFrameRejected = false
852
+ const { client, calls } = makeMockClient({
853
+ 'card.create': { code: 0, data: { card_id: 'ck_recover' } },
854
+ 'im.message.create': { data: { message_id: 'om' } },
855
+ 'cardElement.content': () => {
856
+ if (!firstFrameRejected) {
857
+ firstFrameRejected = true
858
+ // 模拟一个 *非* rate-limit、*非* table-limit 错误
859
+ // 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题
860
+ const err: any = new Error('mystery cardkit error')
861
+ err.code = 999999
862
+ throw err
863
+ }
864
+ return { code: 0 }
865
+ },
866
+ })
867
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
868
+ await sc.ensureCreated()
869
+
870
+ sc.appendReasoning('first thought')
871
+ await sleep(150)
872
+ // 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
873
+ sc.appendReasoning(' second thought')
874
+ await sleep(150)
875
+ // 验证: 至少尝试了 2 次 cardElement.content 调用
876
+ const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
877
+ expect(contentCalls.length).toBeGreaterThanOrEqual(2)
878
+ // 而且 streaming 仍是 active
879
+ expect(sc._isCardKitStreamActive()).toBe(true)
880
+ })
881
+ })
882
+
883
+ describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
884
+ it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
885
+ const { client, calls } = makeMockClient({
886
+ 'card.create': { code: 0, data: { card_id: 'ck_all' } },
887
+ 'im.message.create': { data: { message_id: 'om' } },
888
+ })
889
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
890
+ await sc.ensureCreated()
891
+
892
+ sc.appendReasoning('Should I read file A first?')
893
+ sc.startTool('tu_1', 'Read')
894
+ sc.appendText('Here is the answer.')
895
+ await sleep(150)
896
+
897
+ const lastContent = calls
898
+ .filter((c) => c.api === 'cardkit.v1.cardElement.content')
899
+ .pop()!.args.data.content as string
900
+
901
+ const idxTools = lastContent.indexOf('🛠️')
902
+ const idxReasoning = lastContent.indexOf('思考中')
903
+ const idxAnswer = lastContent.indexOf('Here is the answer')
904
+
905
+ expect(idxTools).toBeGreaterThan(-1)
906
+ expect(idxReasoning).toBeGreaterThan(-1)
907
+ expect(idxAnswer).toBeGreaterThan(-1)
908
+ // tools 在最顶部 → reasoning 居中 → answer 在底部
909
+ expect(idxTools).toBeLessThan(idxReasoning)
910
+ expect(idxReasoning).toBeLessThan(idxAnswer)
911
+ })
912
+
913
+ it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
914
+ let resolveCreate: (() => void) | null = null
915
+ const createLatch = new Promise<void>((r) => { resolveCreate = r })
916
+
917
+ const { client, calls } = makeMockClient({
918
+ 'card.create': async () => {
919
+ await createLatch
920
+ return { code: 0, data: { card_id: 'ck_slow' } }
921
+ },
922
+ 'im.message.create': { data: { message_id: 'om' } },
923
+ })
924
+ const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
925
+
926
+ // 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
927
+ const creating = sc.ensureCreated()
928
+ // 让事件循环推进到 create 被 await
929
+ await sleep(10)
930
+ sc.startTool('tu_1', 'Glob')
931
+
932
+ // 此时 cardMessageReady 仍是 false —— 没有任何 flush
933
+ const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
934
+ expect(contentBefore.length).toBe(0)
935
+
936
+ // 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
937
+ resolveCreate!()
938
+ await creating
939
+ await sleep(150)
940
+
941
+ const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
942
+ expect(contentAfter.length).toBeGreaterThan(0)
943
+ const last = contentAfter[contentAfter.length - 1]!
944
+ expect(last.args.data.content).toContain('Glob')
945
+ expect(last.args.data.content).toContain('🛠️')
946
+ })
947
+ })
adapters/feishu/card-errors.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Feishu CardKit API 错误码解析与谓词
3
+ *
4
+ * 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
5
+ *
6
+ * Lark SDK 抛出的错误对象结构有多种:
7
+ * - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
8
+ * - Axios 风格: error.response.data.{code, msg}
9
+ * - data.code 嵌套(某些包装层)
10
+ *
11
+ * 此模块把这些统一成 { code, subCode, errMsg } 结构,
12
+ * 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Error code constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** 卡片 API 级别错误码。 */
20
+ export const CARD_ERROR = {
21
+ /** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
22
+ RATE_LIMITED: 230020,
23
+ /** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
24
+ CARD_CONTENT_FAILED: 230099,
25
+ } as const
26
+
27
+ /**
28
+ * 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
29
+ * 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
30
+ */
31
+ export const CARD_CONTENT_SUB_ERROR = {
32
+ /** 卡片元素(表格等)数量超限 */
33
+ ELEMENT_LIMIT: 11310,
34
+ } as const
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Code extraction
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function coerceCode(value: unknown): number | undefined {
41
+ if (typeof value === 'number' && Number.isFinite(value)) return value
42
+ if (typeof value === 'string') {
43
+ const parsed = Number(value)
44
+ if (Number.isFinite(parsed)) return parsed
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ /**
50
+ * 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
51
+ * - `{ code }` (SDK 直接挂载)
52
+ * - `{ data: { code } }` (响应体嵌套)
53
+ * - `{ response: { data: { code } } }` (Axios 风格)
54
+ */
55
+ export function extractLarkApiCode(err: unknown): number | undefined {
56
+ if (!err || typeof err !== 'object') return undefined
57
+ const e = err as {
58
+ code?: unknown
59
+ data?: { code?: unknown }
60
+ response?: { data?: { code?: unknown } }
61
+ }
62
+ return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Sub-error extraction
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
71
+ *
72
+ * 示例输入:
73
+ * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
74
+ * 返回: 11310
75
+ */
76
+ export function extractSubCode(msg: string): number | null {
77
+ const match = /ErrCode:\s*(\d+)/.exec(msg)
78
+ if (!match) return null
79
+ const code = Number(match[1])
80
+ return Number.isFinite(code) ? code : null
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Structured error parsing
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export type CardApiErrorInfo = {
88
+ code: number
89
+ subCode: number | null
90
+ errMsg: string
91
+ }
92
+
93
+ /**
94
+ * 从任意抛错对象中解析卡片 API 错误结构。
95
+ *
96
+ * 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
97
+ */
98
+ export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
99
+ const code = extractLarkApiCode(err)
100
+ if (code === undefined) return null
101
+
102
+ // 按优先级提取 msg 文本
103
+ let errMsg = ''
104
+ if (err && typeof err === 'object') {
105
+ const e = err as {
106
+ msg?: unknown
107
+ message?: unknown
108
+ response?: { data?: { msg?: unknown } }
109
+ }
110
+ if (typeof e.msg === 'string') {
111
+ errMsg = e.msg
112
+ } else if (typeof e.response?.data?.msg === 'string') {
113
+ errMsg = e.response.data.msg
114
+ } else if (typeof e.message === 'string') {
115
+ errMsg = e.message
116
+ }
117
+ }
118
+
119
+ const subCode = extractSubCode(errMsg)
120
+ return { code, subCode, errMsg }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Helper predicates
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** 判断错误是否为卡片发送频率限制(230020)。 */
128
+ export function isCardRateLimitError(err: unknown): boolean {
129
+ const parsed = parseCardApiError(err)
130
+ if (!parsed) return false
131
+ return parsed.code === CARD_ERROR.RATE_LIMITED
132
+ }
133
+
134
+ /**
135
+ * 判断错误是否为卡片表格数超限。
136
+ *
137
+ * 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
138
+ * (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
139
+ *
140
+ * 实际生产错误格式(openclaw-lark 2026-03 实测):
141
+ * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
142
+ */
143
+ export function isCardTableLimitError(err: unknown): boolean {
144
+ const parsed = parseCardApiError(err)
145
+ if (!parsed) return false
146
+ return (
147
+ parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
148
+ parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
149
+ /table number over limit/i.test(parsed.errMsg)
150
+ )
151
+ }
adapters/feishu/cardkit.ts ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 飞书 CardKit API 薄封装
3
+ *
4
+ * 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。
5
+ *
6
+ * 五步流程:
7
+ * 1. createCardEntity() —— 创建卡片实体,返回 card_id
8
+ * 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
9
+ * 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
10
+ * 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
11
+ * 5. updateCardKitCard() —— 全量替换卡片为最终态
12
+ *
13
+ * 关键约束:
14
+ * - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝
15
+ * - streamCardContent 传的是**完整累计文本**,不是 delta
16
+ * - 必须关闭 streaming_mode 后卡片才能被用户交互
17
+ *
18
+ * 参考实现: openclaw-lark/src/card/cardkit.ts
19
+ */
20
+
21
+ import type * as Lark from '@larksuiteoapi/node-sdk'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
28
+ * `cardElement.content()` 更新的那一个 markdown 元素。 */
29
+ export const STREAMING_ELEMENT_ID = 'streaming_content'
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * SDK 返回的通用响应结构。
37
+ * SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
38
+ * 我们统一当成 CardKitResponse 处理以免到处 `as any`。
39
+ */
40
+ type CardKitResponse = {
41
+ code?: number
42
+ msg?: string
43
+ data?: Record<string, unknown>
44
+ [key: string]: unknown
45
+ }
46
+
47
+ /** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
48
+ * 可被 card-errors.ts 的 parseCardApiError 识别。 */
49
+ export class CardKitApiError extends Error {
50
+ readonly code: number
51
+ readonly msg: string
52
+
53
+ constructor(params: { api: string; code: number; msg: string; context: string }) {
54
+ const { api, code, msg, context } = params
55
+ super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
56
+ this.name = 'CardKitApiError'
57
+ this.code = code
58
+ this.msg = msg
59
+ }
60
+ }
61
+
62
+ type LarkClient = Lark.Client
63
+
64
+ const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
65
+
66
+ function getImCardRequestTimeoutMs(): number {
67
+ const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
68
+ const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
69
+ return Number.isFinite(parsed) && parsed > 0
70
+ ? parsed
71
+ : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
72
+ }
73
+
74
+ export async function withImCardRequestTimeout<T>(
75
+ api: string,
76
+ request: () => Promise<T>,
77
+ ): Promise<T> {
78
+ const timeoutMs = getImCardRequestTimeoutMs()
79
+ let timer: ReturnType<typeof setTimeout> | undefined
80
+
81
+ try {
82
+ return await Promise.race([
83
+ Promise.resolve().then(request),
84
+ new Promise<never>((_, reject) => {
85
+ timer = setTimeout(() => {
86
+ reject(new Error(`${api} timed out after ${timeoutMs}ms`))
87
+ }, timeoutMs)
88
+ }),
89
+ ])
90
+ } finally {
91
+ if (timer) clearTimeout(timer)
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Response check
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
101
+ *
102
+ * Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
103
+ * 判断是速率限制还是真错误。
104
+ */
105
+ function assertCardKitOk(params: {
106
+ resp: CardKitResponse
107
+ api: string
108
+ context: string
109
+ }): void {
110
+ const { resp, api, context } = params
111
+ const code = resp.code
112
+ if (code !== undefined && code !== 0) {
113
+ throw new CardKitApiError({
114
+ api,
115
+ code,
116
+ msg: typeof resp.msg === 'string' ? resp.msg : '',
117
+ context,
118
+ })
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Step 1 — createCardEntity
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * 创建一张 CardKit 卡片实体,返回 card_id。
128
+ *
129
+ * 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
130
+ *
131
+ * @param client Lark SDK client
132
+ * @param card Schema 2.0 格式的卡片 JSON
133
+ * @returns 飞书分配的 card_id(失败时抛错)
134
+ */
135
+ export async function createCardEntity(
136
+ client: LarkClient,
137
+ card: Record<string, unknown>,
138
+ ): Promise<string> {
139
+ // SDK 返回类型不完整,cast 到运行时实际结构
140
+ const resp = (await withImCardRequestTimeout('card.create', () =>
141
+ client.cardkit.v1.card.create({
142
+ data: {
143
+ type: 'card_json',
144
+ data: JSON.stringify(card),
145
+ },
146
+ }),
147
+ )) as unknown as CardKitResponse
148
+
149
+ assertCardKitOk({
150
+ resp,
151
+ api: 'card.create',
152
+ context: `cardLen=${JSON.stringify(card).length}`,
153
+ })
154
+
155
+ // 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id
156
+ const cardId =
157
+ (resp.data?.card_id as string | undefined) ??
158
+ (resp.card_id as string | undefined)
159
+
160
+ if (!cardId) {
161
+ throw new CardKitApiError({
162
+ api: 'card.create',
163
+ code: resp.code ?? -1,
164
+ msg: 'response missing card_id',
165
+ context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
166
+ })
167
+ }
168
+ return cardId
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Step 2 — sendCardAsMessage
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * 把 CardKit 卡片通过 IM 消息挂到聊天窗。
177
+ *
178
+ * content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
179
+ * msg_type 固定为 `interactive`。
180
+ *
181
+ * @param client Lark SDK client
182
+ * @param chatId 目标 chat_id
183
+ * @param cardId CardKit card_id(由 createCardEntity 产生)
184
+ * @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create
185
+ * @returns 飞书分配的 message_id
186
+ */
187
+ export async function sendCardAsMessage(
188
+ client: LarkClient,
189
+ chatId: string,
190
+ cardId: string,
191
+ replyToMessageId?: string,
192
+ ): Promise<string> {
193
+ const content = JSON.stringify({
194
+ type: 'card',
195
+ data: { card_id: cardId },
196
+ })
197
+
198
+ if (replyToMessageId) {
199
+ const resp = await withImCardRequestTimeout('im.message.reply', () =>
200
+ client.im.message.reply({
201
+ path: { message_id: replyToMessageId },
202
+ data: { content, msg_type: 'interactive' },
203
+ }),
204
+ )
205
+ const messageId = resp.data?.message_id
206
+ if (!messageId) {
207
+ throw new CardKitApiError({
208
+ api: 'im.message.reply',
209
+ code: -1,
210
+ msg: 'response missing message_id',
211
+ context: `cardId=${cardId}`,
212
+ })
213
+ }
214
+ return messageId
215
+ }
216
+
217
+ const resp = await withImCardRequestTimeout('im.message.create', () =>
218
+ client.im.message.create({
219
+ params: { receive_id_type: 'chat_id' },
220
+ data: {
221
+ receive_id: chatId,
222
+ msg_type: 'interactive',
223
+ content,
224
+ },
225
+ }),
226
+ )
227
+ const messageId = resp.data?.message_id
228
+ if (!messageId) {
229
+ throw new CardKitApiError({
230
+ api: 'im.message.create',
231
+ code: -1,
232
+ msg: 'response missing message_id',
233
+ context: `chatId=${chatId} cardId=${cardId}`,
234
+ })
235
+ }
236
+ return messageId
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Step 3 — streamCardContent
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端
245
+ * 渲染打字机效果。
246
+ *
247
+ * **重要**: `content` 必须传**完整累计文本**,不是 delta。
248
+ * sequence 必须**单调递增**,否则飞书拒绝。
249
+ *
250
+ * @param client Lark SDK client
251
+ * @param cardId CardKit card_id
252
+ * @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID)
253
+ * @param content 完整累计文本
254
+ * @param sequence 单调递增序列号
255
+ */
256
+ export async function streamCardContent(
257
+ client: LarkClient,
258
+ cardId: string,
259
+ elementId: string,
260
+ content: string,
261
+ sequence: number,
262
+ ): Promise<void> {
263
+ const resp = (await withImCardRequestTimeout('cardElement.content', () =>
264
+ client.cardkit.v1.cardElement.content({
265
+ data: { content, sequence },
266
+ path: { card_id: cardId, element_id: elementId },
267
+ }),
268
+ )) as unknown as CardKitResponse
269
+
270
+ assertCardKitOk({
271
+ resp,
272
+ api: 'cardElement.content',
273
+ context: `seq=${sequence} len=${content.length}`,
274
+ })
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Step 4 — setCardStreamingMode
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /**
282
+ * 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`,
283
+ * 否则卡片会保持"只读"状态,用户点按钮没反应。
284
+ */
285
+ export async function setCardStreamingMode(
286
+ client: LarkClient,
287
+ cardId: string,
288
+ streamingMode: boolean,
289
+ sequence: number,
290
+ ): Promise<void> {
291
+ const resp = (await withImCardRequestTimeout('card.settings', () =>
292
+ client.cardkit.v1.card.settings({
293
+ data: {
294
+ settings: JSON.stringify({ streaming_mode: streamingMode }),
295
+ sequence,
296
+ },
297
+ path: { card_id: cardId },
298
+ }),
299
+ )) as unknown as CardKitResponse
300
+
301
+ assertCardKitOk({
302
+ resp,
303
+ api: 'card.settings',
304
+ context: `seq=${sequence} streaming_mode=${streamingMode}`,
305
+ })
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Step 5 — updateCardKitCard
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
314
+ * (加 header template、footer、完成样式等)。
315
+ */
316
+ export async function updateCardKitCard(
317
+ client: LarkClient,
318
+ cardId: string,
319
+ card: Record<string, unknown>,
320
+ sequence: number,
321
+ ): Promise<void> {
322
+ const resp = (await withImCardRequestTimeout('card.update', () =>
323
+ client.cardkit.v1.card.update({
324
+ data: {
325
+ card: { type: 'card_json', data: JSON.stringify(card) },
326
+ sequence,
327
+ },
328
+ path: { card_id: cardId },
329
+ }),
330
+ )) as unknown as CardKitResponse
331
+
332
+ assertCardKitOk({
333
+ resp,
334
+ api: 'card.update',
335
+ context: `seq=${sequence} cardId=${cardId}`,
336
+ })
337
+ }