chenbhao commited on
Commit
3a0e256
·
1 Parent(s): 92e96ba

chore: rm desktop and adapter

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. adapters/README.md +0 -101
  2. adapters/bun.lock +0 -150
  3. adapters/common/__tests__/chat-queue.test.ts +0 -61
  4. adapters/common/__tests__/config.test.ts +0 -113
  5. adapters/common/__tests__/format.test.ts +0 -210
  6. adapters/common/__tests__/http-client.test.ts +0 -147
  7. adapters/common/__tests__/message-buffer.test.ts +0 -117
  8. adapters/common/__tests__/message-dedup.test.ts +0 -57
  9. adapters/common/__tests__/pairing.test.ts +0 -22
  10. adapters/common/__tests__/permission.test.ts +0 -43
  11. adapters/common/__tests__/session-store.test.ts +0 -97
  12. adapters/common/__tests__/ws-bridge.test.ts +0 -210
  13. adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  14. adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  15. adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  16. adapters/common/attachment/attachment-limits.ts +0 -58
  17. adapters/common/attachment/attachment-store.ts +0 -121
  18. adapters/common/attachment/attachment-types.ts +0 -29
  19. adapters/common/attachment/image-block-watcher.ts +0 -94
  20. adapters/common/chat-queue.ts +0 -24
  21. adapters/common/config.ts +0 -184
  22. adapters/common/format.ts +0 -323
  23. adapters/common/http-client.ts +0 -207
  24. adapters/common/message-buffer.ts +0 -100
  25. adapters/common/message-dedup.ts +0 -57
  26. adapters/common/pairing.ts +0 -150
  27. adapters/common/permission.ts +0 -70
  28. adapters/common/session-store.ts +0 -82
  29. adapters/common/ws-bridge.ts +0 -289
  30. adapters/dingtalk/__tests__/ai-card.test.ts +0 -101
  31. adapters/dingtalk/__tests__/helpers.test.ts +0 -58
  32. adapters/dingtalk/__tests__/permission-card.test.ts +0 -47
  33. adapters/dingtalk/__tests__/stream-state.test.ts +0 -78
  34. adapters/dingtalk/ai-card.ts +0 -305
  35. adapters/dingtalk/helpers.ts +0 -113
  36. adapters/dingtalk/index.ts +0 -713
  37. adapters/dingtalk/media.ts +0 -84
  38. adapters/dingtalk/permission-card.ts +0 -135
  39. adapters/dingtalk/stream-state.ts +0 -30
  40. adapters/feishu/__tests__/card-errors.test.ts +0 -194
  41. adapters/feishu/__tests__/cardkit.test.ts +0 -295
  42. adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  43. adapters/feishu/__tests__/feishu.test.ts +0 -899
  44. adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  45. adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  46. adapters/feishu/__tests__/media.test.ts +0 -120
  47. adapters/feishu/__tests__/streaming-card.test.ts +0 -947
  48. adapters/feishu/card-errors.ts +0 -151
  49. adapters/feishu/cardkit.ts +0 -337
  50. adapters/feishu/extract-payload.ts +0 -95
adapters/README.md DELETED
@@ -1,101 +0,0 @@
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 DELETED
@@ -1,150 +0,0 @@
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 DELETED
@@ -1,61 +0,0 @@
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 DELETED
@@ -1,113 +0,0 @@
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 DELETED
@@ -1,210 +0,0 @@
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 DELETED
@@ -1,147 +0,0 @@
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 DELETED
@@ -1,117 +0,0 @@
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 DELETED
@@ -1,57 +0,0 @@
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 DELETED
@@ -1,22 +0,0 @@
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 DELETED
@@ -1,43 +0,0 @@
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 DELETED
@@ -1,97 +0,0 @@
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 DELETED
@@ -1,210 +0,0 @@
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 DELETED
@@ -1,52 +0,0 @@
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 DELETED
@@ -1,108 +0,0 @@
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 DELETED
@@ -1,115 +0,0 @@
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 DELETED
@@ -1,58 +0,0 @@
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 DELETED
@@ -1,121 +0,0 @@
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 DELETED
@@ -1,29 +0,0 @@
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 DELETED
@@ -1,94 +0,0 @@
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 DELETED
@@ -1,24 +0,0 @@
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 DELETED
@@ -1,184 +0,0 @@
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 DELETED
@@ -1,323 +0,0 @@
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 DELETED
@@ -1,207 +0,0 @@
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 DELETED
@@ -1,100 +0,0 @@
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 DELETED
@@ -1,57 +0,0 @@
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 DELETED
@@ -1,150 +0,0 @@
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 DELETED
@@ -1,70 +0,0 @@
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 DELETED
@@ -1,82 +0,0 @@
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 DELETED
@@ -1,289 +0,0 @@
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 DELETED
@@ -1,101 +0,0 @@
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 DELETED
@@ -1,58 +0,0 @@
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 DELETED
@@ -1,47 +0,0 @@
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 DELETED
@@ -1,78 +0,0 @@
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 DELETED
@@ -1,305 +0,0 @@
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 DELETED
@@ -1,113 +0,0 @@
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 DELETED
@@ -1,713 +0,0 @@
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 DELETED
@@ -1,84 +0,0 @@
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 DELETED
@@ -1,135 +0,0 @@
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 DELETED
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,194 +0,0 @@
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 DELETED
@@ -1,295 +0,0 @@
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 DELETED
@@ -1,77 +0,0 @@
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 DELETED
@@ -1,899 +0,0 @@
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 DELETED
@@ -1,290 +0,0 @@
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 DELETED
@@ -1,353 +0,0 @@
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 DELETED
@@ -1,120 +0,0 @@
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 DELETED
@@ -1,947 +0,0 @@
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 DELETED
@@ -1,151 +0,0 @@
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 DELETED
@@ -1,337 +0,0 @@
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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
adapters/feishu/extract-payload.ts DELETED
@@ -1,95 +0,0 @@
1
- /**
2
- * Feishu inbound message parser.
3
- *
4
- * Converts a raw Feishu `im.message.receive_v1` event payload (the JSON
5
- * string inside `message.content` plus its `message_type`) into a
6
- * structured `InboundPayload` containing:
7
- * - plain text (for direct forwarding to Claude)
8
- * - a list of `PendingDownload` refs describing any attachments we
9
- * need to fetch via FeishuMediaService.downloadResource()
10
- *
11
- * Supports the five message_type values we care about:
12
- * - text → text only
13
- * - post → rich text (text nodes + img + file elements)
14
- * - image → single image_key
15
- * - file → single file_key
16
- * - file_archive → single file_key (same shape as file)
17
- *
18
- * Any other shape returns an empty payload (text: '', downloads: []).
19
- */
20
-
21
- export type PendingDownload =
22
- | { kind: 'image'; fileKey: string; fileName?: string }
23
- | { kind: 'file'; fileKey: string; fileName?: string }
24
-
25
- export interface InboundPayload {
26
- text: string
27
- pendingDownloads: PendingDownload[]
28
- }
29
-
30
- export function extractInboundPayload(content: string, msgType: string): InboundPayload {
31
- let parsed: any
32
- try {
33
- parsed = JSON.parse(content)
34
- } catch {
35
- return { text: '', pendingDownloads: [] }
36
- }
37
-
38
- if (msgType === 'text') {
39
- return {
40
- text: typeof parsed.text === 'string' ? parsed.text : '',
41
- pendingDownloads: [],
42
- }
43
- }
44
-
45
- if (msgType === 'image') {
46
- if (typeof parsed.image_key === 'string' && parsed.image_key) {
47
- return {
48
- text: '',
49
- pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }],
50
- }
51
- }
52
- return { text: '', pendingDownloads: [] }
53
- }
54
-
55
- if (msgType === 'file' || msgType === 'file_archive') {
56
- if (typeof parsed.file_key === 'string' && parsed.file_key) {
57
- return {
58
- text: '',
59
- pendingDownloads: [
60
- {
61
- kind: 'file',
62
- fileKey: parsed.file_key,
63
- fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined,
64
- },
65
- ],
66
- }
67
- }
68
- return { text: '', pendingDownloads: [] }
69
- }
70
-
71
- if (msgType === 'post') {
72
- const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[]
73
- const flat = nodes.flat()
74
- const textParts: string[] = []
75
- const downloads: PendingDownload[] = []
76
- for (const node of flat) {
77
- if (!node || typeof node !== 'object') continue
78
- if (node.tag === 'text' || node.tag === 'md') {
79
- const t = node.text ?? node.content ?? ''
80
- if (typeof t === 'string') textParts.push(t)
81
- } else if (node.tag === 'img' && typeof node.image_key === 'string') {
82
- downloads.push({ kind: 'image', fileKey: node.image_key })
83
- } else if (node.tag === 'file' && typeof node.file_key === 'string') {
84
- downloads.push({
85
- kind: 'file',
86
- fileKey: node.file_key,
87
- fileName: typeof node.file_name === 'string' ? node.file_name : undefined,
88
- })
89
- }
90
- }
91
- return { text: textParts.join(''), pendingDownloads: downloads }
92
- }
93
-
94
- return { text: '', pendingDownloads: [] }
95
- }