feat: desktop
Browse filescd desktop && bun run tauri dev
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +6 -0
- adapters/README.md +101 -0
- adapters/bun.lock +150 -0
- adapters/common/__tests__/chat-queue.test.ts +61 -0
- adapters/common/__tests__/config.test.ts +113 -0
- adapters/common/__tests__/format.test.ts +210 -0
- adapters/common/__tests__/http-client.test.ts +147 -0
- adapters/common/__tests__/message-buffer.test.ts +117 -0
- adapters/common/__tests__/message-dedup.test.ts +57 -0
- adapters/common/__tests__/pairing.test.ts +22 -0
- adapters/common/__tests__/permission.test.ts +43 -0
- adapters/common/__tests__/session-store.test.ts +97 -0
- adapters/common/__tests__/ws-bridge.test.ts +210 -0
- adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
- adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
- adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
- adapters/common/attachment/attachment-limits.ts +58 -0
- adapters/common/attachment/attachment-store.ts +121 -0
- adapters/common/attachment/attachment-types.ts +29 -0
- adapters/common/attachment/image-block-watcher.ts +94 -0
- adapters/common/chat-queue.ts +24 -0
- adapters/common/config.ts +184 -0
- adapters/common/format.ts +323 -0
- adapters/common/http-client.ts +207 -0
- adapters/common/message-buffer.ts +100 -0
- adapters/common/message-dedup.ts +57 -0
- adapters/common/pairing.ts +150 -0
- adapters/common/permission.ts +70 -0
- adapters/common/session-store.ts +82 -0
- adapters/common/ws-bridge.ts +289 -0
- adapters/dingtalk/__tests__/ai-card.test.ts +101 -0
- adapters/dingtalk/__tests__/helpers.test.ts +58 -0
- adapters/dingtalk/__tests__/permission-card.test.ts +47 -0
- adapters/dingtalk/__tests__/stream-state.test.ts +78 -0
- adapters/dingtalk/ai-card.ts +305 -0
- adapters/dingtalk/helpers.ts +113 -0
- adapters/dingtalk/index.ts +713 -0
- adapters/dingtalk/media.ts +84 -0
- adapters/dingtalk/permission-card.ts +135 -0
- adapters/dingtalk/stream-state.ts +30 -0
- adapters/feishu/__tests__/card-errors.test.ts +194 -0
- adapters/feishu/__tests__/cardkit.test.ts +295 -0
- adapters/feishu/__tests__/extract-payload.test.ts +77 -0
- adapters/feishu/__tests__/feishu.test.ts +899 -0
- adapters/feishu/__tests__/flush-controller.test.ts +290 -0
- adapters/feishu/__tests__/markdown-style.test.ts +353 -0
- adapters/feishu/__tests__/media.test.ts +120 -0
- adapters/feishu/__tests__/streaming-card.test.ts +947 -0
- adapters/feishu/card-errors.ts +151 -0
- adapters/feishu/cardkit.ts +337 -0
.gitignore
CHANGED
|
@@ -3,3 +3,9 @@ dist/
|
|
| 3 |
cli
|
| 4 |
.codex
|
| 5 |
VersperClaw
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
cli
|
| 4 |
.codex
|
| 5 |
VersperClaw
|
| 6 |
+
|
| 7 |
+
# Desktop (Tauri) build artifacts
|
| 8 |
+
desktop/src-tauri/target/
|
| 9 |
+
desktop/src-tauri/binaries/
|
| 10 |
+
desktop/src-tauri/gen/
|
| 11 |
+
desktop/pnpm-lock.yaml
|
adapters/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Claude Code IM Adapters
|
| 2 |
+
|
| 3 |
+
当前目录只放 IM Adapter 运行时代码。
|
| 4 |
+
|
| 5 |
+
用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
|
| 6 |
+
|
| 7 |
+
- `docs/im/index.md`
|
| 8 |
+
- `docs/im/wechat.md`
|
| 9 |
+
- `docs/im/dingtalk.md`
|
| 10 |
+
- `docs/im/telegram.md`
|
| 11 |
+
- `docs/im/feishu.md`
|
| 12 |
+
|
| 13 |
+
## 当前方案摘要
|
| 14 |
+
|
| 15 |
+
当前真实链路是:
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
Desktop Webapp Settings
|
| 19 |
+
-> /api/adapters
|
| 20 |
+
-> ~/.claude/adapters.json
|
| 21 |
+
-> adapters/<platform>/index.ts
|
| 22 |
+
-> /api/sessions + /ws/:sessionId
|
| 23 |
+
-> Claude Code session
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
注意两点:
|
| 27 |
+
|
| 28 |
+
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
|
| 29 |
+
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run dingtalk`、`bun run telegram` 或 `bun run feishu`
|
| 30 |
+
|
| 31 |
+
## 快速启动
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
cd adapters
|
| 35 |
+
bun install
|
| 36 |
+
bun run telegram
|
| 37 |
+
# 或
|
| 38 |
+
bun run feishu
|
| 39 |
+
# 或
|
| 40 |
+
bun run wechat
|
| 41 |
+
# 或
|
| 42 |
+
bun run dingtalk
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## 开发
|
| 46 |
+
|
| 47 |
+
### 运行测试
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
cd adapters
|
| 51 |
+
bun test
|
| 52 |
+
bun test common/
|
| 53 |
+
bun test telegram/
|
| 54 |
+
bun test feishu/
|
| 55 |
+
bun test wechat/
|
| 56 |
+
bun test dingtalk/
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 目录结构
|
| 60 |
+
|
| 61 |
+
```text
|
| 62 |
+
adapters/
|
| 63 |
+
├── common/
|
| 64 |
+
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
|
| 65 |
+
├── telegram/
|
| 66 |
+
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
|
| 67 |
+
├── feishu/
|
| 68 |
+
│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
|
| 69 |
+
│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
|
| 70 |
+
├── wechat/
|
| 71 |
+
│ ├── protocol.ts # 微信 iLink QR 登录 / getupdates / sendmessage 协议封装
|
| 72 |
+
│ └── index.ts # 微信文本聊天 Adapter
|
| 73 |
+
├── dingtalk/
|
| 74 |
+
│ ├── helpers.ts # 钉钉 Stream 消息解析与会话键
|
| 75 |
+
│ └── index.ts # 钉钉扫码绑定 / Stream 文本聊天 Adapter
|
| 76 |
+
├── package.json
|
| 77 |
+
├── tsconfig.json
|
| 78 |
+
└── README.md
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## 附件收发
|
| 82 |
+
|
| 83 |
+
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
|
| 84 |
+
|
| 85 |
+
**入站(用户 → Claude):**
|
| 86 |
+
|
| 87 |
+
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
|
| 88 |
+
- Telegram: photo、document、video、audio、voice
|
| 89 |
+
|
| 90 |
+
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
|
| 91 |
+
|
| 92 |
+
**出站(Claude → 用户):**
|
| 93 |
+
|
| 94 |
+
Agent 流式文本里的 markdown 图片引用 `` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
|
| 95 |
+
|
| 96 |
+
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
|
| 97 |
+
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
|
| 98 |
+
|
| 99 |
+
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
|
| 100 |
+
|
| 101 |
+
设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
|
adapters/bun.lock
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lockfileVersion": 1,
|
| 3 |
+
"configVersion": 1,
|
| 4 |
+
"workspaces": {
|
| 5 |
+
"": {
|
| 6 |
+
"name": "claude-code-im-adapters",
|
| 7 |
+
"dependencies": {
|
| 8 |
+
"@larksuiteoapi/node-sdk": "^1.60.0",
|
| 9 |
+
"dingtalk-stream": "2.1.4",
|
| 10 |
+
"grammy": "^1.42.0",
|
| 11 |
+
"ws": "^8.18.0",
|
| 12 |
+
},
|
| 13 |
+
"devDependencies": {
|
| 14 |
+
"@types/ws": "^8.5.0",
|
| 15 |
+
"bun-types": "latest",
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
"overrides": {
|
| 20 |
+
"follow-redirects": "^1.16.0",
|
| 21 |
+
"protobufjs": "^7.5.5",
|
| 22 |
+
},
|
| 23 |
+
"packages": {
|
| 24 |
+
"@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
|
| 25 |
+
|
| 26 |
+
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="],
|
| 27 |
+
|
| 28 |
+
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
| 29 |
+
|
| 30 |
+
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
| 31 |
+
|
| 32 |
+
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
| 33 |
+
|
| 34 |
+
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
| 35 |
+
|
| 36 |
+
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
| 37 |
+
|
| 38 |
+
"@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
| 39 |
+
|
| 40 |
+
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
|
| 41 |
+
|
| 42 |
+
"@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
| 43 |
+
|
| 44 |
+
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
| 45 |
+
|
| 46 |
+
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
| 47 |
+
|
| 48 |
+
"@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
| 49 |
+
|
| 50 |
+
"@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
| 51 |
+
|
| 52 |
+
"abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
| 53 |
+
|
| 54 |
+
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
| 55 |
+
|
| 56 |
+
"axios": ["axios@1.13.6", "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
| 57 |
+
|
| 58 |
+
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
| 59 |
+
|
| 60 |
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
| 61 |
+
|
| 62 |
+
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
| 63 |
+
|
| 64 |
+
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
| 65 |
+
|
| 66 |
+
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
| 67 |
+
|
| 68 |
+
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
| 69 |
+
|
| 70 |
+
"dingtalk-stream": ["dingtalk-stream@2.1.4", "https://registry.npmmirror.com/dingtalk-stream/-/dingtalk-stream-2.1.4.tgz", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="],
|
| 71 |
+
|
| 72 |
+
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
| 73 |
+
|
| 74 |
+
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
| 75 |
+
|
| 76 |
+
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
| 77 |
+
|
| 78 |
+
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
| 79 |
+
|
| 80 |
+
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
| 81 |
+
|
| 82 |
+
"event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
| 83 |
+
|
| 84 |
+
"follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
| 85 |
+
|
| 86 |
+
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
| 87 |
+
|
| 88 |
+
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
| 89 |
+
|
| 90 |
+
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
| 91 |
+
|
| 92 |
+
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
| 93 |
+
|
| 94 |
+
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
| 95 |
+
|
| 96 |
+
"grammy": ["grammy@1.42.0", "https://registry.npmmirror.com/grammy/-/grammy-1.42.0.tgz", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="],
|
| 97 |
+
|
| 98 |
+
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
| 99 |
+
|
| 100 |
+
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
| 101 |
+
|
| 102 |
+
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
| 103 |
+
|
| 104 |
+
"lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
|
| 105 |
+
|
| 106 |
+
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
| 107 |
+
|
| 108 |
+
"lodash.pickby": ["lodash.pickby@4.6.0", "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
|
| 109 |
+
|
| 110 |
+
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
| 111 |
+
|
| 112 |
+
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
| 113 |
+
|
| 114 |
+
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
| 115 |
+
|
| 116 |
+
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
| 117 |
+
|
| 118 |
+
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
| 119 |
+
|
| 120 |
+
"node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
| 121 |
+
|
| 122 |
+
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
| 123 |
+
|
| 124 |
+
"protobufjs": ["protobufjs@7.5.6", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="],
|
| 125 |
+
|
| 126 |
+
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
| 127 |
+
|
| 128 |
+
"qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
| 129 |
+
|
| 130 |
+
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
| 131 |
+
|
| 132 |
+
"side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
| 133 |
+
|
| 134 |
+
"side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
| 135 |
+
|
| 136 |
+
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
| 137 |
+
|
| 138 |
+
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
| 139 |
+
|
| 140 |
+
"undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
| 141 |
+
|
| 142 |
+
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
| 143 |
+
|
| 144 |
+
"whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
| 145 |
+
|
| 146 |
+
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
| 147 |
+
|
| 148 |
+
"@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
| 149 |
+
}
|
| 150 |
+
}
|
adapters/common/__tests__/chat-queue.test.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'bun:test'
|
| 2 |
+
import { enqueue } from '../chat-queue.js'
|
| 3 |
+
|
| 4 |
+
describe('ChatQueue', () => {
|
| 5 |
+
it('executes tasks for the same chatId serially', async () => {
|
| 6 |
+
const order: number[] = []
|
| 7 |
+
|
| 8 |
+
await Promise.all([
|
| 9 |
+
enqueue('chat-1', async () => {
|
| 10 |
+
await new Promise((r) => setTimeout(r, 30))
|
| 11 |
+
order.push(1)
|
| 12 |
+
}),
|
| 13 |
+
enqueue('chat-1', async () => {
|
| 14 |
+
order.push(2)
|
| 15 |
+
}),
|
| 16 |
+
enqueue('chat-1', async () => {
|
| 17 |
+
order.push(3)
|
| 18 |
+
}),
|
| 19 |
+
])
|
| 20 |
+
|
| 21 |
+
// Wait for all to complete
|
| 22 |
+
await new Promise((r) => setTimeout(r, 50))
|
| 23 |
+
expect(order).toEqual([1, 2, 3])
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('executes tasks for different chatIds in parallel', async () => {
|
| 27 |
+
const order: string[] = []
|
| 28 |
+
|
| 29 |
+
const p1 = enqueue('chat-a', async () => {
|
| 30 |
+
await new Promise((r) => setTimeout(r, 30))
|
| 31 |
+
order.push('a')
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
const p2 = enqueue('chat-b', async () => {
|
| 35 |
+
order.push('b') // should run immediately, not wait for chat-a
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
await Promise.all([p1, p2])
|
| 39 |
+
await new Promise((r) => setTimeout(r, 50))
|
| 40 |
+
|
| 41 |
+
// 'b' should appear before 'a' since chat-a has a delay
|
| 42 |
+
expect(order[0]).toBe('b')
|
| 43 |
+
expect(order[1]).toBe('a')
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
it('continues processing after a task fails', async () => {
|
| 47 |
+
const order: number[] = []
|
| 48 |
+
|
| 49 |
+
await enqueue('chat-err', async () => {
|
| 50 |
+
order.push(1)
|
| 51 |
+
throw new Error('task failed')
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
await enqueue('chat-err', async () => {
|
| 55 |
+
order.push(2) // should still run
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
await new Promise((r) => setTimeout(r, 20))
|
| 59 |
+
expect(order).toEqual([1, 2])
|
| 60 |
+
})
|
| 61 |
+
})
|
adapters/common/__tests__/config.test.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it } from 'bun:test'
|
| 2 |
+
import * as fs from 'node:fs'
|
| 3 |
+
import * as os from 'node:os'
|
| 4 |
+
import * as path from 'node:path'
|
| 5 |
+
import { getConfiguredWorkDir, loadConfig } from '../config.js'
|
| 6 |
+
|
| 7 |
+
describe('adapter config defaults', () => {
|
| 8 |
+
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
| 9 |
+
const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
|
| 10 |
+
const originalAdapterDefaultProjectDir = process.env.ADAPTER_DEFAULT_PROJECT_DIR
|
| 11 |
+
const originalDingtalkPermissionCardTemplateId = process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID
|
| 12 |
+
const originalPwd = process.env.PWD
|
| 13 |
+
|
| 14 |
+
afterEach(() => {
|
| 15 |
+
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
|
| 16 |
+
restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir)
|
| 17 |
+
restoreEnv('ADAPTER_DEFAULT_PROJECT_DIR', originalAdapterDefaultProjectDir)
|
| 18 |
+
restoreEnv('DINGTALK_PERMISSION_CARD_TEMPLATE_ID', originalDingtalkPermissionCardTemplateId)
|
| 19 |
+
restoreEnv('PWD', originalPwd)
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
it('uses the user shell working directory when no default project is configured', () => {
|
| 23 |
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
| 24 |
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-'))
|
| 25 |
+
try {
|
| 26 |
+
process.env.CLAUDE_CONFIG_DIR = configDir
|
| 27 |
+
delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
|
| 28 |
+
process.env.PWD = workDir
|
| 29 |
+
|
| 30 |
+
const config = loadConfig()
|
| 31 |
+
|
| 32 |
+
expect(config.telegram.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 33 |
+
expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 34 |
+
expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 35 |
+
expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 36 |
+
expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir))
|
| 37 |
+
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(workDir))
|
| 38 |
+
} finally {
|
| 39 |
+
fs.rmSync(configDir, { recursive: true, force: true })
|
| 40 |
+
fs.rmSync(workDir, { recursive: true, force: true })
|
| 41 |
+
}
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
it('keeps the explicit default project ahead of the platform default work dir', () => {
|
| 45 |
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
| 46 |
+
const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
|
| 47 |
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-'))
|
| 48 |
+
try {
|
| 49 |
+
fs.writeFileSync(
|
| 50 |
+
path.join(configDir, 'adapters.json'),
|
| 51 |
+
JSON.stringify({ defaultProjectDir }),
|
| 52 |
+
)
|
| 53 |
+
process.env.CLAUDE_CONFIG_DIR = configDir
|
| 54 |
+
process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR = workDir
|
| 55 |
+
|
| 56 |
+
const config = loadConfig()
|
| 57 |
+
|
| 58 |
+
expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir)
|
| 59 |
+
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(defaultProjectDir)
|
| 60 |
+
expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 61 |
+
expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
|
| 62 |
+
} finally {
|
| 63 |
+
fs.rmSync(configDir, { recursive: true, force: true })
|
| 64 |
+
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
|
| 65 |
+
fs.rmSync(workDir, { recursive: true, force: true })
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
it('accepts ADAPTER_DEFAULT_PROJECT_DIR as a sidecar-friendly default work dir override', () => {
|
| 70 |
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
| 71 |
+
const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
|
| 72 |
+
try {
|
| 73 |
+
process.env.CLAUDE_CONFIG_DIR = configDir
|
| 74 |
+
process.env.ADAPTER_DEFAULT_PROJECT_DIR = defaultProjectDir
|
| 75 |
+
delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
|
| 76 |
+
delete process.env.PWD
|
| 77 |
+
|
| 78 |
+
const config = loadConfig()
|
| 79 |
+
|
| 80 |
+
expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(defaultProjectDir))
|
| 81 |
+
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(defaultProjectDir))
|
| 82 |
+
} finally {
|
| 83 |
+
fs.rmSync(configDir, { recursive: true, force: true })
|
| 84 |
+
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
|
| 85 |
+
}
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
it('loads DingTalk permission card template id from file or env', () => {
|
| 89 |
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
| 90 |
+
try {
|
| 91 |
+
fs.writeFileSync(
|
| 92 |
+
path.join(configDir, 'adapters.json'),
|
| 93 |
+
JSON.stringify({ dingtalk: { permissionCardTemplateId: 'file-template' } }),
|
| 94 |
+
)
|
| 95 |
+
process.env.CLAUDE_CONFIG_DIR = configDir
|
| 96 |
+
delete process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID
|
| 97 |
+
expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('file-template')
|
| 98 |
+
|
| 99 |
+
process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID = 'env-template'
|
| 100 |
+
expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('env-template')
|
| 101 |
+
} finally {
|
| 102 |
+
fs.rmSync(configDir, { recursive: true, force: true })
|
| 103 |
+
}
|
| 104 |
+
})
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
function restoreEnv(key: string, value: string | undefined): void {
|
| 108 |
+
if (value === undefined) {
|
| 109 |
+
delete process.env[key]
|
| 110 |
+
} else {
|
| 111 |
+
process.env[key] = value
|
| 112 |
+
}
|
| 113 |
+
}
|
adapters/common/__tests__/format.test.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'bun:test'
|
| 2 |
+
import {
|
| 3 |
+
convertMarkdownTablesToBullets,
|
| 4 |
+
formatImHelp,
|
| 5 |
+
formatImStatus,
|
| 6 |
+
splitMessage,
|
| 7 |
+
formatToolUse,
|
| 8 |
+
formatPermissionRequest,
|
| 9 |
+
truncateInput,
|
| 10 |
+
escapeMarkdownV2,
|
| 11 |
+
} from '../format.js'
|
| 12 |
+
|
| 13 |
+
describe('splitMessage', () => {
|
| 14 |
+
it('returns single chunk for short text', () => {
|
| 15 |
+
expect(splitMessage('hello', 100)).toEqual(['hello'])
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('splits at paragraph boundary', () => {
|
| 19 |
+
const text = 'First paragraph.\n\nSecond paragraph.'
|
| 20 |
+
const chunks = splitMessage(text, 20)
|
| 21 |
+
expect(chunks.length).toBeGreaterThan(1)
|
| 22 |
+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
|
| 23 |
+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('splits at newline if no paragraph break', () => {
|
| 27 |
+
const text = 'Line one\nLine two\nLine three\nLine four'
|
| 28 |
+
const chunks = splitMessage(text, 20)
|
| 29 |
+
expect(chunks.length).toBeGreaterThan(1)
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
it('hard-splits at limit if no natural break', () => {
|
| 33 |
+
const text = 'a'.repeat(50)
|
| 34 |
+
const chunks = splitMessage(text, 20)
|
| 35 |
+
expect(chunks.length).toBe(3) // 20 + 20 + 10
|
| 36 |
+
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
it('preserves all content after splitting', () => {
|
| 40 |
+
const text = 'Hello world. This is a test. Foo bar baz.'
|
| 41 |
+
const chunks = splitMessage(text, 15)
|
| 42 |
+
const joined = chunks.join(' ')
|
| 43 |
+
// All words should be present
|
| 44 |
+
expect(joined).toContain('Hello')
|
| 45 |
+
expect(joined).toContain('test')
|
| 46 |
+
expect(joined).toContain('baz')
|
| 47 |
+
})
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
describe('convertMarkdownTablesToBullets', () => {
|
| 51 |
+
it('converts pipe tables into row-labeled bullets', () => {
|
| 52 |
+
const markdown = [
|
| 53 |
+
'Before',
|
| 54 |
+
'',
|
| 55 |
+
'| Feature | Status | Notes |',
|
| 56 |
+
'| --- | --- | --- |',
|
| 57 |
+
'| Auth | Done | OAuth2 |',
|
| 58 |
+
'| API | WIP | REST only |',
|
| 59 |
+
'',
|
| 60 |
+
'After',
|
| 61 |
+
].join('\n')
|
| 62 |
+
|
| 63 |
+
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
| 64 |
+
'Before',
|
| 65 |
+
'',
|
| 66 |
+
'Auth',
|
| 67 |
+
'• Status: Done',
|
| 68 |
+
'• Notes: OAuth2',
|
| 69 |
+
'',
|
| 70 |
+
'API',
|
| 71 |
+
'• Status: WIP',
|
| 72 |
+
'• Notes: REST only',
|
| 73 |
+
'',
|
| 74 |
+
'After',
|
| 75 |
+
].join('\n'))
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
it('skips empty table cells', () => {
|
| 79 |
+
const markdown = [
|
| 80 |
+
'| Item | Value | Notes |',
|
| 81 |
+
'| --- | --- | --- |',
|
| 82 |
+
'| One | 1 | |',
|
| 83 |
+
].join('\n')
|
| 84 |
+
|
| 85 |
+
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
| 86 |
+
'One',
|
| 87 |
+
'• Value: 1',
|
| 88 |
+
].join('\n'))
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
it('leaves non-table pipe text unchanged', () => {
|
| 92 |
+
const markdown = 'Use foo | bar as plain text.'
|
| 93 |
+
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
it('does not rewrite pipe tables inside fenced code blocks', () => {
|
| 97 |
+
const markdown = [
|
| 98 |
+
'```',
|
| 99 |
+
'| Feature | Status |',
|
| 100 |
+
'| --- | --- |',
|
| 101 |
+
'| Auth | Done |',
|
| 102 |
+
'```',
|
| 103 |
+
].join('\n')
|
| 104 |
+
|
| 105 |
+
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
| 106 |
+
})
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
describe('formatToolUse', () => {
|
| 110 |
+
it('includes tool name and input preview', () => {
|
| 111 |
+
const result = formatToolUse('Bash', { command: 'npm test' })
|
| 112 |
+
expect(result).toContain('🔧 Bash')
|
| 113 |
+
expect(result).toContain('npm test')
|
| 114 |
+
})
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
describe('formatPermissionRequest', () => {
|
| 118 |
+
it('includes tool name, input preview, and request ID', () => {
|
| 119 |
+
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
|
| 120 |
+
expect(result).toContain('🔐')
|
| 121 |
+
expect(result).toContain('Bash')
|
| 122 |
+
expect(result).toContain('abcde')
|
| 123 |
+
expect(result).toContain('rm -rf')
|
| 124 |
+
})
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
describe('truncateInput', () => {
|
| 128 |
+
it('returns short input as-is', () => {
|
| 129 |
+
expect(truncateInput('hello', 100)).toBe('hello')
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
it('truncates long input with ellipsis', () => {
|
| 133 |
+
const long = 'x'.repeat(300)
|
| 134 |
+
const result = truncateInput(long, 100)
|
| 135 |
+
expect(result.length).toBe(101) // 100 chars + '…'
|
| 136 |
+
expect(result.endsWith('…')).toBe(true)
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
it('handles objects by stringifying', () => {
|
| 140 |
+
const result = truncateInput({ key: 'value' }, 100)
|
| 141 |
+
expect(result).toContain('key')
|
| 142 |
+
expect(result).toContain('value')
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
it('handles unserializable input', () => {
|
| 146 |
+
const circular: any = {}
|
| 147 |
+
circular.self = circular
|
| 148 |
+
expect(truncateInput(circular, 100)).toBe('(unserializable)')
|
| 149 |
+
})
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
describe('escapeMarkdownV2', () => {
|
| 153 |
+
it('escapes special characters', () => {
|
| 154 |
+
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
| 155 |
+
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
|
| 156 |
+
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
it('leaves plain text unchanged', () => {
|
| 160 |
+
expect(escapeMarkdownV2('hello world')).toBe('hello world')
|
| 161 |
+
})
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
describe('formatImHelp', () => {
|
| 165 |
+
it('lists the lightweight IM commands', () => {
|
| 166 |
+
const text = formatImHelp()
|
| 167 |
+
expect(text).toContain('/new')
|
| 168 |
+
expect(text).toContain('/projects')
|
| 169 |
+
expect(text).toContain('/status')
|
| 170 |
+
expect(text).toContain('/clear')
|
| 171 |
+
expect(text).toContain('/stop')
|
| 172 |
+
expect(text).toContain('/help')
|
| 173 |
+
expect(text).toContain('项目列表')
|
| 174 |
+
expect(text).toContain('/allow <id>')
|
| 175 |
+
})
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
describe('formatImStatus', () => {
|
| 179 |
+
it('formats an active session summary for mobile reading', () => {
|
| 180 |
+
const text = formatImStatus({
|
| 181 |
+
sessionId: 'abc1234567890',
|
| 182 |
+
projectName: 'claude-code-haha',
|
| 183 |
+
branch: 'main',
|
| 184 |
+
model: 'claude-sonnet',
|
| 185 |
+
state: 'tool_executing',
|
| 186 |
+
verb: 'Running tests',
|
| 187 |
+
pendingPermissionCount: 1,
|
| 188 |
+
taskCounts: {
|
| 189 |
+
total: 4,
|
| 190 |
+
pending: 1,
|
| 191 |
+
inProgress: 2,
|
| 192 |
+
completed: 1,
|
| 193 |
+
},
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
expect(text).toContain('项目: claude-code-haha (main)')
|
| 197 |
+
expect(text).toContain('会话: abc12345…')
|
| 198 |
+
expect(text).toContain('模型: claude-sonnet')
|
| 199 |
+
expect(text).toContain('状态: 执行工具中 (Running tests)')
|
| 200 |
+
expect(text).toContain('审批: 1 个待确认')
|
| 201 |
+
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
it('returns a friendly empty-session message when nothing is active', () => {
|
| 205 |
+
const text = formatImStatus(null)
|
| 206 |
+
expect(text).toContain('当前没有活动会话')
|
| 207 |
+
expect(text).toContain('/new')
|
| 208 |
+
expect(text).toContain('/projects')
|
| 209 |
+
})
|
| 210 |
+
})
|
adapters/common/__tests__/http-client.test.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
| 2 |
+
import * as fs from 'node:fs'
|
| 3 |
+
import * as os from 'node:os'
|
| 4 |
+
import * as path from 'node:path'
|
| 5 |
+
import { AdapterHttpClient } from '../http-client.js'
|
| 6 |
+
|
| 7 |
+
describe('AdapterHttpClient', () => {
|
| 8 |
+
let client: AdapterHttpClient
|
| 9 |
+
const originalFetch = globalThis.fetch
|
| 10 |
+
|
| 11 |
+
beforeEach(() => {
|
| 12 |
+
client = new AdapterHttpClient('ws://127.0.0.1:3456')
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
afterEach(() => {
|
| 16 |
+
globalThis.fetch = originalFetch
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
it('derives HTTP URL from WS URL', () => {
|
| 20 |
+
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
|
| 21 |
+
|
| 22 |
+
const secure = new AdapterHttpClient('wss://example.com:443')
|
| 23 |
+
expect(secure.httpBaseUrl).toBe('https://example.com:443')
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('createSession calls POST /api/sessions', async () => {
|
| 27 |
+
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
| 28 |
+
globalThis.fetch = mock(() =>
|
| 29 |
+
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
|
| 30 |
+
status: 201,
|
| 31 |
+
headers: { 'Content-Type': 'application/json' },
|
| 32 |
+
}))
|
| 33 |
+
) as any
|
| 34 |
+
|
| 35 |
+
const sessionId = await client.createSession('/path/to/project')
|
| 36 |
+
expect(sessionId).toBe(mockSessionId)
|
| 37 |
+
|
| 38 |
+
const call = (globalThis.fetch as any).mock.calls[0]
|
| 39 |
+
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
|
| 40 |
+
const body = JSON.parse(call[1].body)
|
| 41 |
+
expect(body.workDir).toBe('/path/to/project')
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
|
| 45 |
+
const mockProjects = [
|
| 46 |
+
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
|
| 47 |
+
]
|
| 48 |
+
globalThis.fetch = mock(() =>
|
| 49 |
+
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
|
| 50 |
+
headers: { 'Content-Type': 'application/json' },
|
| 51 |
+
}))
|
| 52 |
+
) as any
|
| 53 |
+
|
| 54 |
+
const projects = await client.listRecentProjects()
|
| 55 |
+
expect(projects).toHaveLength(1)
|
| 56 |
+
expect(projects[0].projectName).toBe('my-app')
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
|
| 60 |
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
| 61 |
+
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
|
| 62 |
+
try {
|
| 63 |
+
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
| 64 |
+
globalThis.fetch = mock(() => {
|
| 65 |
+
throw new Error('recent projects should not be queried for absolute paths')
|
| 66 |
+
}) as any
|
| 67 |
+
|
| 68 |
+
const result = await client.matchProject(projectDir)
|
| 69 |
+
|
| 70 |
+
expect(result.project?.realPath).toBe(fs.realpathSync(projectDir))
|
| 71 |
+
expect(result.project?.projectName).toBe(path.basename(projectDir))
|
| 72 |
+
expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
|
| 73 |
+
} finally {
|
| 74 |
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
| 75 |
+
}
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
it('matchProject rejects absolute local project paths outside allowed roots', async () => {
|
| 79 |
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
| 80 |
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-'))
|
| 81 |
+
try {
|
| 82 |
+
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
| 83 |
+
globalThis.fetch = mock(() => {
|
| 84 |
+
throw new Error('recent projects should not be queried for rejected absolute paths')
|
| 85 |
+
}) as any
|
| 86 |
+
|
| 87 |
+
const result = await client.matchProject(projectDir)
|
| 88 |
+
|
| 89 |
+
expect(result.project).toBeUndefined()
|
| 90 |
+
expect(result.ambiguous).toBeUndefined()
|
| 91 |
+
expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
|
| 92 |
+
} finally {
|
| 93 |
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
| 94 |
+
fs.rmSync(projectDir, { recursive: true, force: true })
|
| 95 |
+
}
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
it('createSession throws on server error', async () => {
|
| 99 |
+
globalThis.fetch = mock(() =>
|
| 100 |
+
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
|
| 101 |
+
status: 400,
|
| 102 |
+
headers: { 'Content-Type': 'application/json' },
|
| 103 |
+
}))
|
| 104 |
+
) as any
|
| 105 |
+
|
| 106 |
+
expect(client.createSession('')).rejects.toThrow()
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
|
| 110 |
+
globalThis.fetch = mock(() =>
|
| 111 |
+
Promise.resolve(new Response(JSON.stringify({
|
| 112 |
+
branch: 'main',
|
| 113 |
+
repoName: 'claude-code-haha',
|
| 114 |
+
workDir: '/repo/claude-code-haha',
|
| 115 |
+
changedFiles: 2,
|
| 116 |
+
}), {
|
| 117 |
+
headers: { 'Content-Type': 'application/json' },
|
| 118 |
+
}))
|
| 119 |
+
) as any
|
| 120 |
+
|
| 121 |
+
const gitInfo = await client.getGitInfo('session-123')
|
| 122 |
+
expect(gitInfo.repoName).toBe('claude-code-haha')
|
| 123 |
+
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
| 124 |
+
'http://127.0.0.1:3456/api/sessions/session-123/git-info',
|
| 125 |
+
)
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
|
| 129 |
+
globalThis.fetch = mock(() =>
|
| 130 |
+
Promise.resolve(new Response(JSON.stringify({
|
| 131 |
+
tasks: [
|
| 132 |
+
{ id: '1', subject: 'Fix bug', status: 'in_progress' },
|
| 133 |
+
{ id: '2', subject: 'Write docs', status: 'pending' },
|
| 134 |
+
],
|
| 135 |
+
}), {
|
| 136 |
+
headers: { 'Content-Type': 'application/json' },
|
| 137 |
+
}))
|
| 138 |
+
) as any
|
| 139 |
+
|
| 140 |
+
const tasks = await client.getTasksForSession('session-123')
|
| 141 |
+
expect(tasks).toHaveLength(2)
|
| 142 |
+
expect(tasks[0]?.status).toBe('in_progress')
|
| 143 |
+
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
| 144 |
+
'http://127.0.0.1:3456/api/tasks/lists/session-123',
|
| 145 |
+
)
|
| 146 |
+
})
|
| 147 |
+
})
|
adapters/common/__tests__/message-buffer.test.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'bun:test'
|
| 2 |
+
import { MessageBuffer } from '../message-buffer.js'
|
| 3 |
+
|
| 4 |
+
describe('MessageBuffer', () => {
|
| 5 |
+
it('accumulates text and flushes on complete', async () => {
|
| 6 |
+
const flushed: Array<{ text: string; isComplete: boolean }> = []
|
| 7 |
+
const buf = new MessageBuffer(
|
| 8 |
+
(text, isComplete) => { flushed.push({ text, isComplete }) },
|
| 9 |
+
500, // 500ms interval
|
| 10 |
+
1000, // 1000 char threshold
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
buf.append('Hello ')
|
| 14 |
+
buf.append('World')
|
| 15 |
+
await buf.complete()
|
| 16 |
+
|
| 17 |
+
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
| 18 |
+
const allText = flushed.map((f) => f.text).join('')
|
| 19 |
+
expect(allText).toBe('Hello World')
|
| 20 |
+
// Last flush should be marked complete
|
| 21 |
+
expect(flushed[flushed.length - 1]!.isComplete).toBe(true)
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('flushes when character threshold is reached', async () => {
|
| 25 |
+
const flushed: string[] = []
|
| 26 |
+
const buf = new MessageBuffer(
|
| 27 |
+
(text) => { flushed.push(text) },
|
| 28 |
+
10000, // very long interval (won't trigger)
|
| 29 |
+
10, // 10 char threshold
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
buf.append('12345678901') // 11 chars > threshold
|
| 33 |
+
|
| 34 |
+
// Wait for microtask
|
| 35 |
+
await new Promise((r) => setTimeout(r, 10))
|
| 36 |
+
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
| 37 |
+
|
| 38 |
+
buf.reset()
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
it('flushes on timer interval', async () => {
|
| 42 |
+
const flushed: string[] = []
|
| 43 |
+
const buf = new MessageBuffer(
|
| 44 |
+
(text) => { flushed.push(text) },
|
| 45 |
+
50, // 50ms interval
|
| 46 |
+
1000,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
buf.append('hi')
|
| 50 |
+
|
| 51 |
+
// Wait for timer
|
| 52 |
+
await new Promise((r) => setTimeout(r, 80))
|
| 53 |
+
expect(flushed).toContain('hi')
|
| 54 |
+
|
| 55 |
+
buf.reset()
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
it('does not flush empty buffer on complete', async () => {
|
| 59 |
+
const flushed: string[] = []
|
| 60 |
+
const buf = new MessageBuffer(
|
| 61 |
+
(text) => { flushed.push(text) },
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
await buf.complete()
|
| 65 |
+
expect(flushed.length).toBe(0)
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('waits for an in-flight flush before complete resolves', async () => {
|
| 69 |
+
let releaseFlush!: () => void
|
| 70 |
+
let flushStarted = false
|
| 71 |
+
const flushed: Array<{ text: string; isComplete: boolean }> = []
|
| 72 |
+
const buf = new MessageBuffer(
|
| 73 |
+
async (text, isComplete) => {
|
| 74 |
+
flushStarted = true
|
| 75 |
+
flushed.push({ text, isComplete })
|
| 76 |
+
await new Promise<void>((resolve) => {
|
| 77 |
+
releaseFlush = resolve
|
| 78 |
+
})
|
| 79 |
+
},
|
| 80 |
+
10000,
|
| 81 |
+
3,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
buf.append('abcd')
|
| 85 |
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
| 86 |
+
expect(flushStarted).toBe(true)
|
| 87 |
+
|
| 88 |
+
let completeResolved = false
|
| 89 |
+
const completing = buf.complete().then(() => {
|
| 90 |
+
completeResolved = true
|
| 91 |
+
})
|
| 92 |
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
| 93 |
+
expect(completeResolved).toBe(false)
|
| 94 |
+
|
| 95 |
+
releaseFlush()
|
| 96 |
+
await completing
|
| 97 |
+
expect(completeResolved).toBe(true)
|
| 98 |
+
expect(flushed).toEqual([{ text: 'abcd', isComplete: false }])
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
it('resets properly between messages', async () => {
|
| 102 |
+
const flushed: string[] = []
|
| 103 |
+
const buf = new MessageBuffer(
|
| 104 |
+
(text) => { flushed.push(text) },
|
| 105 |
+
500,
|
| 106 |
+
1000,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
buf.append('first')
|
| 110 |
+
buf.reset()
|
| 111 |
+
buf.append('second')
|
| 112 |
+
await buf.complete()
|
| 113 |
+
|
| 114 |
+
const allText = flushed.map((f) => f).join('')
|
| 115 |
+
expect(allText).toBe('second')
|
| 116 |
+
})
|
| 117 |
+
})
|
adapters/common/__tests__/message-dedup.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
| 2 |
+
import { MessageDedup } from '../message-dedup.js'
|
| 3 |
+
|
| 4 |
+
describe('MessageDedup', () => {
|
| 5 |
+
let dedup: MessageDedup
|
| 6 |
+
|
| 7 |
+
beforeEach(() => {
|
| 8 |
+
dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
afterEach(() => {
|
| 12 |
+
dedup.destroy()
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
it('returns true for new messages', () => {
|
| 16 |
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
| 17 |
+
expect(dedup.tryRecord('msg-2')).toBe(true)
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
it('returns false for duplicate messages', () => {
|
| 21 |
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
| 22 |
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
| 23 |
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('allows same ID after TTL expires', async () => {
|
| 27 |
+
const shortDedup = new MessageDedup(50, 100) // 50ms TTL
|
| 28 |
+
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
| 29 |
+
expect(shortDedup.tryRecord('msg-1')).toBe(false)
|
| 30 |
+
await new Promise((r) => setTimeout(r, 60))
|
| 31 |
+
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
| 32 |
+
shortDedup.destroy()
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('evicts oldest entry when at capacity', () => {
|
| 36 |
+
const smallDedup = new MessageDedup(60_000, 3) // max 3 entries
|
| 37 |
+
expect(smallDedup.tryRecord('a')).toBe(true)
|
| 38 |
+
expect(smallDedup.tryRecord('b')).toBe(true)
|
| 39 |
+
expect(smallDedup.tryRecord('c')).toBe(true)
|
| 40 |
+
// Adding 4th should evict 'a'
|
| 41 |
+
expect(smallDedup.tryRecord('d')).toBe(true)
|
| 42 |
+
// 'a' was evicted, should be treated as new
|
| 43 |
+
expect(smallDedup.tryRecord('a')).toBe(true)
|
| 44 |
+
// Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted
|
| 45 |
+
// 'c' should still be deduped (was not evicted)
|
| 46 |
+
expect(smallDedup.tryRecord('c')).toBe(false)
|
| 47 |
+
smallDedup.destroy()
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
it('handles distinct messages independently', () => {
|
| 51 |
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
| 52 |
+
expect(dedup.tryRecord('msg-2')).toBe(true)
|
| 53 |
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
| 54 |
+
expect(dedup.tryRecord('msg-2')).toBe(false)
|
| 55 |
+
expect(dedup.tryRecord('msg-3')).toBe(true)
|
| 56 |
+
})
|
| 57 |
+
})
|
adapters/common/__tests__/pairing.test.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'bun:test'
|
| 2 |
+
import { isPaired } from '../pairing.js'
|
| 3 |
+
|
| 4 |
+
describe('pairing platform support', () => {
|
| 5 |
+
it('checks DingTalk paired users with the same shared access rule', () => {
|
| 6 |
+
expect(isPaired('dingtalk', 'staff-1', {
|
| 7 |
+
dingtalk: {
|
| 8 |
+
pairedUsers: [{ userId: 'staff-1', displayName: 'DingTalk User', pairedAt: Date.now() }],
|
| 9 |
+
allowedUsers: [],
|
| 10 |
+
},
|
| 11 |
+
})).toBe(true)
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
it('keeps empty DingTalk allow and pair lists closed by default', () => {
|
| 15 |
+
expect(isPaired('dingtalk', 'staff-1', {
|
| 16 |
+
dingtalk: {
|
| 17 |
+
pairedUsers: [],
|
| 18 |
+
allowedUsers: [],
|
| 19 |
+
},
|
| 20 |
+
})).toBe(false)
|
| 21 |
+
})
|
| 22 |
+
})
|
adapters/common/__tests__/permission.test.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'bun:test'
|
| 2 |
+
import {
|
| 3 |
+
formatPermissionDecisionStatus,
|
| 4 |
+
formatPermissionInstructions,
|
| 5 |
+
parsePermissionCommand,
|
| 6 |
+
parsePermitCallbackData,
|
| 7 |
+
} from '../permission.js'
|
| 8 |
+
|
| 9 |
+
describe('permission helpers', () => {
|
| 10 |
+
it('parses text permission commands', () => {
|
| 11 |
+
expect(parsePermissionCommand('/allow req-1')).toEqual({ requestId: 'req-1', allowed: true })
|
| 12 |
+
expect(parsePermissionCommand('/always req-2')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' })
|
| 13 |
+
expect(parsePermissionCommand('/allow-always req-3')).toEqual({ requestId: 'req-3', allowed: true, rule: 'always' })
|
| 14 |
+
expect(parsePermissionCommand('/deny req-4')).toEqual({ requestId: 'req-4', allowed: false })
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
it('parses short replies when one permission is pending', () => {
|
| 18 |
+
const pending = new Set(['req-1'])
|
| 19 |
+
expect(parsePermissionCommand('1', pending)).toEqual({ requestId: 'req-1', allowed: true })
|
| 20 |
+
expect(parsePermissionCommand('2', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
|
| 21 |
+
expect(parsePermissionCommand('3', pending)).toEqual({ requestId: 'req-1', allowed: false })
|
| 22 |
+
expect(parsePermissionCommand('/always', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
|
| 23 |
+
expect(parsePermissionCommand('永久允许', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('does not parse short replies when multiple permissions are pending', () => {
|
| 27 |
+
expect(parsePermissionCommand('1', new Set(['req-1', 'req-2']))).toBeNull()
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
it('parses callback permission actions', () => {
|
| 31 |
+
expect(parsePermitCallbackData('permit:req-1:yes')).toEqual({ requestId: 'req-1', allowed: true })
|
| 32 |
+
expect(parsePermitCallbackData('permit:req-2:always')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' })
|
| 33 |
+
expect(parsePermitCallbackData('permit:req-3:no')).toEqual({ requestId: 'req-3', allowed: false })
|
| 34 |
+
expect(parsePermitCallbackData('permit:req-4:unknown')).toBeNull()
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
it('formats text fallback and status labels', () => {
|
| 38 |
+
expect(formatPermissionInstructions('req-1')).toContain('回复 1')
|
| 39 |
+
expect(formatPermissionInstructions('req-1')).toContain('/always req-1')
|
| 40 |
+
expect(formatPermissionDecisionStatus({ allowed: true, rule: 'always' })).toContain('永久允许')
|
| 41 |
+
expect(formatPermissionDecisionStatus({ allowed: false })).toContain('拒绝')
|
| 42 |
+
})
|
| 43 |
+
})
|
adapters/common/__tests__/session-store.test.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
| 2 |
+
import * as fs from 'node:fs'
|
| 3 |
+
import * as path from 'node:path'
|
| 4 |
+
import * as os from 'node:os'
|
| 5 |
+
import { SessionStore } from '../session-store.js'
|
| 6 |
+
|
| 7 |
+
describe('SessionStore', () => {
|
| 8 |
+
let tmpDir: string
|
| 9 |
+
let store: SessionStore
|
| 10 |
+
|
| 11 |
+
beforeEach(() => {
|
| 12 |
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
|
| 13 |
+
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
afterEach(() => {
|
| 17 |
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
it('returns null for unknown chatId', () => {
|
| 21 |
+
expect(store.get('unknown')).toBeNull()
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('stores and retrieves a session', () => {
|
| 25 |
+
store.set('chat-1', 'uuid-aaa', '/path/to/project')
|
| 26 |
+
const entry = store.get('chat-1')
|
| 27 |
+
expect(entry).not.toBeNull()
|
| 28 |
+
expect(entry!.sessionId).toBe('uuid-aaa')
|
| 29 |
+
expect(entry!.workDir).toBe('/path/to/project')
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
it('overwrites existing entry on set', () => {
|
| 33 |
+
store.set('chat-1', 'uuid-aaa', '/old')
|
| 34 |
+
store.set('chat-1', 'uuid-bbb', '/new')
|
| 35 |
+
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
it('deletes an entry', () => {
|
| 39 |
+
store.set('chat-1', 'uuid-aaa', '/path')
|
| 40 |
+
store.delete('chat-1')
|
| 41 |
+
expect(store.get('chat-1')).toBeNull()
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
it('deletes every chat entry bound to a sessionId', () => {
|
| 45 |
+
store.set('chat-1', 'uuid-shared', '/project-a')
|
| 46 |
+
store.set('chat-2', 'uuid-other', '/project-b')
|
| 47 |
+
store.set('chat-3', 'uuid-shared', '/project-c')
|
| 48 |
+
|
| 49 |
+
const removed = store.deleteBySessionId('uuid-shared')
|
| 50 |
+
|
| 51 |
+
expect(removed.sort()).toEqual(['chat-1', 'chat-3'])
|
| 52 |
+
expect(store.get('chat-1')).toBeNull()
|
| 53 |
+
expect(store.get('chat-3')).toBeNull()
|
| 54 |
+
expect(store.get('chat-2')!.sessionId).toBe('uuid-other')
|
| 55 |
+
|
| 56 |
+
const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
| 57 |
+
expect(reloaded.get('chat-1')).toBeNull()
|
| 58 |
+
expect(reloaded.get('chat-3')).toBeNull()
|
| 59 |
+
expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other')
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => {
|
| 63 |
+
store.set('chat-1', 'uuid-stale', '/project')
|
| 64 |
+
const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
| 65 |
+
|
| 66 |
+
expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1'])
|
| 67 |
+
|
| 68 |
+
expect(store.get('chat-1')).toBeNull()
|
| 69 |
+
expect(store.listAll()).toEqual([])
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
it('returns an empty list when deleting an unknown sessionId', () => {
|
| 73 |
+
store.set('chat-1', 'uuid-aaa', '/project')
|
| 74 |
+
|
| 75 |
+
expect(store.deleteBySessionId('uuid-missing')).toEqual([])
|
| 76 |
+
expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
it('persists to disk and reloads', () => {
|
| 80 |
+
store.set('chat-1', 'uuid-aaa', '/path')
|
| 81 |
+
|
| 82 |
+
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
| 83 |
+
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
it('handles missing file gracefully', () => {
|
| 87 |
+
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
|
| 88 |
+
expect(store2.get('anything')).toBeNull()
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
it('lists all entries', () => {
|
| 92 |
+
store.set('chat-1', 'uuid-1', '/a')
|
| 93 |
+
store.set('chat-2', 'uuid-2', '/b')
|
| 94 |
+
const all = store.listAll()
|
| 95 |
+
expect(all).toHaveLength(2)
|
| 96 |
+
})
|
| 97 |
+
})
|
adapters/common/__tests__/ws-bridge.test.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
| 2 |
+
import { WsBridge } from '../ws-bridge.js'
|
| 3 |
+
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
|
| 4 |
+
|
| 5 |
+
describe('WsBridge', () => {
|
| 6 |
+
let bridge: WsBridge
|
| 7 |
+
|
| 8 |
+
beforeEach(() => {
|
| 9 |
+
bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
|
| 10 |
+
})
|
| 11 |
+
|
| 12 |
+
afterEach(() => {
|
| 13 |
+
bridge.destroy()
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
it('connectSession connects with provided sessionId', () => {
|
| 17 |
+
const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
|
| 18 |
+
expect(result).toBe(true)
|
| 19 |
+
expect(bridge.hasSession('chat-1')).toBe(true)
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
it('connectSession for different chatIds creates separate sessions', () => {
|
| 23 |
+
bridge.connectSession('chat-1', 'uuid-1')
|
| 24 |
+
bridge.connectSession('chat-2', 'uuid-2')
|
| 25 |
+
expect(bridge.hasSession('chat-1')).toBe(true)
|
| 26 |
+
expect(bridge.hasSession('chat-2')).toBe(true)
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
it('resetSession removes the session', () => {
|
| 30 |
+
bridge.connectSession('chat-reset', 'uuid-reset')
|
| 31 |
+
bridge.resetSession('chat-reset')
|
| 32 |
+
expect(bridge.hasSession('chat-reset')).toBe(false)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('sendUserMessage returns false when no open connection', () => {
|
| 36 |
+
bridge.connectSession('chat-offline', 'uuid-offline')
|
| 37 |
+
expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
it('sendPermissionResponse returns false when no open connection', () => {
|
| 41 |
+
bridge.connectSession('chat-perm', 'uuid-perm')
|
| 42 |
+
expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
it('sendStopGeneration returns false when no open connection', () => {
|
| 46 |
+
bridge.connectSession('chat-stop', 'uuid-stop')
|
| 47 |
+
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
it('destroy cleans up all sessions', () => {
|
| 51 |
+
bridge.connectSession('a', 'uuid-a')
|
| 52 |
+
bridge.connectSession('b', 'uuid-b')
|
| 53 |
+
bridge.destroy()
|
| 54 |
+
expect(bridge.hasSession('a')).toBe(false)
|
| 55 |
+
expect(bridge.hasSession('b')).toBe(false)
|
| 56 |
+
})
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
// ---------------------------------------------------------------------------
|
| 60 |
+
// Integration: per-chat handler serialization
|
| 61 |
+
//
|
| 62 |
+
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
|
| 63 |
+
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
|
| 64 |
+
// state msg 1's continuation is about to clear.
|
| 65 |
+
// ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
describe('WsBridge: handler serialization', () => {
|
| 68 |
+
let server: WebSocketServer
|
| 69 |
+
let port: number
|
| 70 |
+
let connections: WsServerSocket[]
|
| 71 |
+
let serverUrl: string
|
| 72 |
+
|
| 73 |
+
beforeEach(async () => {
|
| 74 |
+
connections = []
|
| 75 |
+
// port 0 → let the OS pick a free one
|
| 76 |
+
server = new WebSocketServer({ port: 0 })
|
| 77 |
+
server.on('connection', (ws) => {
|
| 78 |
+
connections.push(ws)
|
| 79 |
+
})
|
| 80 |
+
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
|
| 81 |
+
port = (server.address() as { port: number }).port
|
| 82 |
+
serverUrl = `ws://127.0.0.1:${port}`
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
afterEach(async () => {
|
| 86 |
+
// Forcibly kill any server-side sockets (not graceful close) so
|
| 87 |
+
// WebSocketServer.close() doesn't wait for client FIN.
|
| 88 |
+
for (const ws of connections) {
|
| 89 |
+
try { ws.terminate() } catch {}
|
| 90 |
+
}
|
| 91 |
+
await new Promise<void>((resolve) => {
|
| 92 |
+
const t = setTimeout(() => resolve(), 500) // hard cap
|
| 93 |
+
server.close(() => {
|
| 94 |
+
clearTimeout(t)
|
| 95 |
+
resolve()
|
| 96 |
+
})
|
| 97 |
+
})
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
async function waitForServerConnection(): Promise<WsServerSocket> {
|
| 101 |
+
if (connections[0]) return connections[0]
|
| 102 |
+
await new Promise<void>((resolve, reject) => {
|
| 103 |
+
const onConnection = () => {
|
| 104 |
+
clearTimeout(timer)
|
| 105 |
+
resolve()
|
| 106 |
+
}
|
| 107 |
+
const timer = setTimeout(() => {
|
| 108 |
+
server.off('connection', onConnection)
|
| 109 |
+
reject(new Error('Timed out waiting for test WebSocket connection'))
|
| 110 |
+
}, 500)
|
| 111 |
+
server.once('connection', onConnection)
|
| 112 |
+
})
|
| 113 |
+
return connections[0]!
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
it('processes handler calls in strict FIFO order per chatId', async () => {
|
| 117 |
+
const bridge = new WsBridge(serverUrl, 'test')
|
| 118 |
+
const events: string[] = []
|
| 119 |
+
|
| 120 |
+
// The handler simulates an async side effect that takes varying time.
|
| 121 |
+
// If handlers ran concurrently, fast msgs could finish before slow ones,
|
| 122 |
+
// producing an out-of-order `events` array.
|
| 123 |
+
bridge.onServerMessage('chat-1', async (msg: any) => {
|
| 124 |
+
const tag = msg.tag as string
|
| 125 |
+
const delay = msg.delay as number
|
| 126 |
+
events.push(`start:${tag}`)
|
| 127 |
+
await new Promise((r) => setTimeout(r, delay))
|
| 128 |
+
events.push(`end:${tag}`)
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
bridge.connectSession('chat-1', 'sess-1')
|
| 132 |
+
const ok = await bridge.waitForOpen('chat-1')
|
| 133 |
+
expect(ok).toBe(true)
|
| 134 |
+
const serverWs = await waitForServerConnection()
|
| 135 |
+
|
| 136 |
+
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
|
| 137 |
+
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
|
| 138 |
+
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
|
| 139 |
+
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
|
| 140 |
+
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
|
| 141 |
+
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
|
| 142 |
+
|
| 143 |
+
// Wait long enough for all three handlers to run serially
|
| 144 |
+
await new Promise((r) => setTimeout(r, 200))
|
| 145 |
+
|
| 146 |
+
expect(events).toEqual([
|
| 147 |
+
'start:1', 'end:1',
|
| 148 |
+
'start:2', 'end:2',
|
| 149 |
+
'start:3', 'end:3',
|
| 150 |
+
])
|
| 151 |
+
|
| 152 |
+
bridge.destroy()
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
it('handler error does not break the chain (subsequent messages still run)', async () => {
|
| 156 |
+
const bridge = new WsBridge(serverUrl, 'test')
|
| 157 |
+
const events: string[] = []
|
| 158 |
+
|
| 159 |
+
bridge.onServerMessage('chat-err', async (msg: any) => {
|
| 160 |
+
if (msg.throw) {
|
| 161 |
+
events.push('throwing')
|
| 162 |
+
throw new Error('boom')
|
| 163 |
+
}
|
| 164 |
+
events.push(`ok:${msg.tag}`)
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
bridge.connectSession('chat-err', 'sess-err')
|
| 168 |
+
await bridge.waitForOpen('chat-err')
|
| 169 |
+
const serverWs = await waitForServerConnection()
|
| 170 |
+
|
| 171 |
+
serverWs.send(JSON.stringify({ throw: true }))
|
| 172 |
+
serverWs.send(JSON.stringify({ tag: 'after' }))
|
| 173 |
+
|
| 174 |
+
await new Promise((r) => setTimeout(r, 80))
|
| 175 |
+
|
| 176 |
+
expect(events).toEqual(['throwing', 'ok:after'])
|
| 177 |
+
|
| 178 |
+
bridge.destroy()
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
it('forgets a chat when the server closes the session normally', async () => {
|
| 182 |
+
const bridge = new WsBridge(serverUrl, 'test')
|
| 183 |
+
bridge.onServerMessage('chat-deleted', () => {})
|
| 184 |
+
bridge.connectSession('chat-deleted', 'sess-deleted')
|
| 185 |
+
await bridge.waitForOpen('chat-deleted')
|
| 186 |
+
|
| 187 |
+
const serverWs = await waitForServerConnection()
|
| 188 |
+
serverWs.close(1000, 'session deleted')
|
| 189 |
+
|
| 190 |
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
| 191 |
+
|
| 192 |
+
expect(bridge.hasSession('chat-deleted')).toBe(false)
|
| 193 |
+
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
| 194 |
+
expect(connections).toHaveLength(1)
|
| 195 |
+
|
| 196 |
+
bridge.destroy()
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
it('resetSession clears the handler chain', async () => {
|
| 200 |
+
const bridge = new WsBridge(serverUrl, 'test')
|
| 201 |
+
bridge.onServerMessage('chat-reset', () => {})
|
| 202 |
+
bridge.connectSession('chat-reset', 'sess-reset')
|
| 203 |
+
await bridge.waitForOpen('chat-reset')
|
| 204 |
+
|
| 205 |
+
bridge.resetSession('chat-reset')
|
| 206 |
+
expect(bridge.hasSession('chat-reset')).toBe(false)
|
| 207 |
+
|
| 208 |
+
bridge.destroy()
|
| 209 |
+
})
|
| 210 |
+
})
|
adapters/common/attachment/__tests__/attachment-limits.test.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'bun:test'
|
| 2 |
+
import {
|
| 3 |
+
checkAttachmentLimit,
|
| 4 |
+
IMAGE_MAX_BYTES,
|
| 5 |
+
FILE_MAX_BYTES,
|
| 6 |
+
IMAGE_MIME_WHITELIST,
|
| 7 |
+
} from '../attachment-limits.js'
|
| 8 |
+
|
| 9 |
+
describe('checkAttachmentLimit', () => {
|
| 10 |
+
it('accepts a 1 MB PNG image', () => {
|
| 11 |
+
const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
|
| 12 |
+
expect(result.ok).toBe(true)
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
it('rejects an 11 MB image as too_large', () => {
|
| 16 |
+
const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
|
| 17 |
+
expect(result.ok).toBe(false)
|
| 18 |
+
if (!result.ok) {
|
| 19 |
+
expect(result.reason).toBe('too_large')
|
| 20 |
+
expect(result.hint).toContain('10')
|
| 21 |
+
}
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('rejects an unsupported image mime', () => {
|
| 25 |
+
const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
|
| 26 |
+
expect(result.ok).toBe(false)
|
| 27 |
+
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
it('rejects image/heic (not supported by Claude API)', () => {
|
| 31 |
+
const result = checkAttachmentLimit('image', 500_000, 'image/heic')
|
| 32 |
+
expect(result.ok).toBe(false)
|
| 33 |
+
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
it('accepts a 10 MB PDF file', () => {
|
| 37 |
+
const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
|
| 38 |
+
expect(result.ok).toBe(true)
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
it('rejects a 31 MB file as too_large', () => {
|
| 42 |
+
const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
|
| 43 |
+
expect(result.ok).toBe(false)
|
| 44 |
+
if (!result.ok) expect(result.reason).toBe('too_large')
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
it('exposes the limits as exports', () => {
|
| 48 |
+
expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
|
| 49 |
+
expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
|
| 50 |
+
expect(IMAGE_MIME_WHITELIST).toContain('image/png')
|
| 51 |
+
})
|
| 52 |
+
})
|
adapters/common/attachment/__tests__/attachment-store.test.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
| 2 |
+
import * as fs from 'node:fs/promises'
|
| 3 |
+
import * as fsSync from 'node:fs'
|
| 4 |
+
import * as path from 'node:path'
|
| 5 |
+
import * as os from 'node:os'
|
| 6 |
+
import { AttachmentStore } from '../attachment-store.js'
|
| 7 |
+
|
| 8 |
+
let tmpRoot: string
|
| 9 |
+
|
| 10 |
+
beforeEach(async () => {
|
| 11 |
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
afterEach(async () => {
|
| 15 |
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
describe('AttachmentStore', () => {
|
| 19 |
+
it('writes a buffer and returns the absolute path', async () => {
|
| 20 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 21 |
+
const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
|
| 22 |
+
const written = await store.write(target, Buffer.from('PNGDATA'))
|
| 23 |
+
expect(path.isAbsolute(written)).toBe(true)
|
| 24 |
+
const content = await fs.readFile(written)
|
| 25 |
+
expect(content.toString()).toBe('PNGDATA')
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
it('writes under {root}/{platform}/{sessionId}/', async () => {
|
| 29 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 30 |
+
const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
|
| 31 |
+
expect(target).toContain(path.join('telegram', 'sess-42'))
|
| 32 |
+
expect(target.endsWith('foo.pdf')).toBe(true)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
|
| 36 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 37 |
+
const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
|
| 38 |
+
// The resulting target must still live inside the store root.
|
| 39 |
+
const root = path.resolve(tmpRoot)
|
| 40 |
+
expect(path.resolve(target).startsWith(root)).toBe(true)
|
| 41 |
+
expect(path.basename(target)).not.toContain('..')
|
| 42 |
+
expect(path.basename(target)).not.toContain('/')
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
it('collapses name collisions by prefixing timestamps', async () => {
|
| 46 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 47 |
+
const a = store.resolvePath('feishu', 'sess-1', 'image.png')
|
| 48 |
+
await store.write(a, Buffer.from('first'))
|
| 49 |
+
const b = store.resolvePath('feishu', 'sess-1', 'image.png')
|
| 50 |
+
expect(b).not.toBe(a)
|
| 51 |
+
await store.write(b, Buffer.from('second'))
|
| 52 |
+
const contentB = await fs.readFile(b)
|
| 53 |
+
expect(contentB.toString()).toBe('second')
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
it('gc() removes files older than retentionMs and reports counts', async () => {
|
| 57 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
|
| 58 |
+
const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
|
| 59 |
+
await store.write(target, Buffer.from('STALE'))
|
| 60 |
+
// Age the file manually
|
| 61 |
+
const past = new Date(Date.now() - 10_000)
|
| 62 |
+
await fs.utimes(target, past, past)
|
| 63 |
+
const result = await store.gc()
|
| 64 |
+
expect(result.removed).toBe(1)
|
| 65 |
+
expect(result.bytes).toBe(5)
|
| 66 |
+
await expect(fs.access(target)).rejects.toThrow()
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
it('gc() keeps fresh files', async () => {
|
| 70 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 71 |
+
const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
|
| 72 |
+
await store.write(target, Buffer.from('FRESH'))
|
| 73 |
+
const result = await store.gc()
|
| 74 |
+
expect(result.removed).toBe(0)
|
| 75 |
+
await fs.access(target)
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
it('resolvePath under heavy collision pressure returns unique paths', () => {
|
| 79 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 80 |
+
// First create a file so subsequent resolves hit the collision branch
|
| 81 |
+
const a = store.resolvePath('feishu', 'sess-1', 'race.png')
|
| 82 |
+
fsSync.writeFileSync(a, 'first')
|
| 83 |
+
// Collect 50 resolved paths in a tight loop — none should clash
|
| 84 |
+
const seen = new Set<string>()
|
| 85 |
+
for (let i = 0; i < 50; i++) {
|
| 86 |
+
seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
|
| 87 |
+
}
|
| 88 |
+
expect(seen.size).toBe(50)
|
| 89 |
+
for (const p of seen) {
|
| 90 |
+
expect(p).not.toBe(a)
|
| 91 |
+
}
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
it('gc() cleans orphan .part files after a short grace period', async () => {
|
| 95 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
|
| 96 |
+
// Simulate a crashed write — leave a .part tmp file behind
|
| 97 |
+
const dir = path.join(tmpRoot, 'feishu', 'sess-1')
|
| 98 |
+
await fs.mkdir(dir, { recursive: true })
|
| 99 |
+
const orphan = path.join(dir, 'image.png.1234.5678.part')
|
| 100 |
+
await fs.writeFile(orphan, 'ORPHAN')
|
| 101 |
+
// Age the orphan so gc considers it stale
|
| 102 |
+
const past = new Date(Date.now() - 1000)
|
| 103 |
+
await fs.utimes(orphan, past, past)
|
| 104 |
+
const result = await store.gc()
|
| 105 |
+
expect(result.removed).toBeGreaterThanOrEqual(1)
|
| 106 |
+
await expect(fs.access(orphan)).rejects.toThrow()
|
| 107 |
+
})
|
| 108 |
+
})
|
adapters/common/attachment/__tests__/image-block-watcher.test.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'bun:test'
|
| 2 |
+
import { ImageBlockWatcher } from '../image-block-watcher.js'
|
| 3 |
+
|
| 4 |
+
describe('ImageBlockWatcher', () => {
|
| 5 |
+
it('extracts a markdown image with http URL', () => {
|
| 6 |
+
const w = new ImageBlockWatcher()
|
| 7 |
+
const out = w.feed('Here is  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('')
|
| 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('')
|
| 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('')
|
| 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('')
|
| 50 |
+
const b = w.feed(' repeated  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  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('')
|
| 75 |
+
w.feed(' and ')
|
| 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('')
|
| 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('')
|
| 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(' and ')
|
| 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('  ')
|
| 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('')
|
| 112 |
+
// Not in `;base64,` form → classify returns null → skipped
|
| 113 |
+
expect(out.length).toBe(0)
|
| 114 |
+
})
|
| 115 |
+
})
|
adapters/common/attachment/attachment-limits.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Size and MIME restrictions for IM attachments.
|
| 3 |
+
*
|
| 4 |
+
* Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file)
|
| 5 |
+
* and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's
|
| 6 |
+
* own image size bounds.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
| 10 |
+
export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB
|
| 11 |
+
|
| 12 |
+
export const IMAGE_MIME_WHITELIST = [
|
| 13 |
+
'image/jpeg',
|
| 14 |
+
'image/png',
|
| 15 |
+
'image/gif',
|
| 16 |
+
'image/webp',
|
| 17 |
+
] as const
|
| 18 |
+
|
| 19 |
+
export type LimitCheckResult =
|
| 20 |
+
| { ok: true }
|
| 21 |
+
| { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string }
|
| 22 |
+
|
| 23 |
+
function formatMb(bytes: number): string {
|
| 24 |
+
return (bytes / (1024 * 1024)).toFixed(1)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export function checkAttachmentLimit(
|
| 28 |
+
kind: 'image' | 'file',
|
| 29 |
+
size: number,
|
| 30 |
+
mime?: string,
|
| 31 |
+
): LimitCheckResult {
|
| 32 |
+
if (kind === 'image') {
|
| 33 |
+
if (size > IMAGE_MAX_BYTES) {
|
| 34 |
+
return {
|
| 35 |
+
ok: false,
|
| 36 |
+
reason: 'too_large',
|
| 37 |
+
hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`,
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) {
|
| 41 |
+
return {
|
| 42 |
+
ok: false,
|
| 43 |
+
reason: 'unsupported_mime',
|
| 44 |
+
hint: `📎 暂不支持此图片格式(${mime})`,
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
return { ok: true }
|
| 48 |
+
}
|
| 49 |
+
// kind === 'file'
|
| 50 |
+
if (size > FILE_MAX_BYTES) {
|
| 51 |
+
return {
|
| 52 |
+
ok: false,
|
| 53 |
+
reason: 'too_large',
|
| 54 |
+
hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`,
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
return { ok: true }
|
| 58 |
+
}
|
adapters/common/attachment/attachment-store.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Local staging directory for IM-downloaded resources.
|
| 3 |
+
*
|
| 4 |
+
* Layout: {root}/{platform}/{sessionId}/{safeName}
|
| 5 |
+
* Default root: ~/.claude/im-downloads
|
| 6 |
+
*
|
| 7 |
+
* Responsibilities:
|
| 8 |
+
* - Generate unique, safe paths from (platform, sessionId, originalName)
|
| 9 |
+
* - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
|
| 10 |
+
* - GC files that haven't been touched for `retentionMs` (default 24h)
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import * as fs from 'node:fs/promises'
|
| 14 |
+
import * as fsSync from 'node:fs'
|
| 15 |
+
import type { Dirent } from 'node:fs'
|
| 16 |
+
import * as path from 'node:path'
|
| 17 |
+
import * as os from 'node:os'
|
| 18 |
+
import type { ImPlatform } from './attachment-types.js'
|
| 19 |
+
|
| 20 |
+
export interface AttachmentStoreConfig {
|
| 21 |
+
root: string
|
| 22 |
+
retentionMs: number
|
| 23 |
+
/** Grace window before a `.part` orphan (left behind by a crashed writer)
|
| 24 |
+
* is eligible for GC. Default 10 minutes. */
|
| 25 |
+
orphanGraceMs: number
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
| 29 |
+
const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
|
| 30 |
+
|
| 31 |
+
function defaultRoot(): string {
|
| 32 |
+
return path.join(os.homedir(), '.claude', 'im-downloads')
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/** Strip path separators / .. / control chars from a filename. */
|
| 36 |
+
function sanitizeFilename(name: string): string {
|
| 37 |
+
// eslint-disable-next-line no-control-regex
|
| 38 |
+
const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
|
| 39 |
+
const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
|
| 40 |
+
return cleaned.trim() || 'unnamed'
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export class AttachmentStore {
|
| 44 |
+
private readonly root: string
|
| 45 |
+
private readonly retentionMs: number
|
| 46 |
+
private readonly orphanGraceMs: number
|
| 47 |
+
|
| 48 |
+
constructor(config?: Partial<AttachmentStoreConfig>) {
|
| 49 |
+
this.root = config?.root ?? defaultRoot()
|
| 50 |
+
this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
|
| 51 |
+
this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/** Compute the target path. Creates parent dirs on demand.
|
| 55 |
+
* If a file with the same name already exists, prefix with a timestamp
|
| 56 |
+
* to avoid clobbering. */
|
| 57 |
+
resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
|
| 58 |
+
const safeSession = sanitizeFilename(sessionId)
|
| 59 |
+
const dir = path.join(this.root, platform, safeSession)
|
| 60 |
+
fsSync.mkdirSync(dir, { recursive: true })
|
| 61 |
+
const safeName = sanitizeFilename(name)
|
| 62 |
+
const candidate = path.join(dir, safeName)
|
| 63 |
+
if (!fsSync.existsSync(candidate)) return candidate
|
| 64 |
+
const { name: base, ext } = path.parse(safeName)
|
| 65 |
+
// Collisions are rare in practice, but multiple downloads landing in the
|
| 66 |
+
// same millisecond must still produce unique paths — append a random
|
| 67 |
+
// suffix so the bare timestamp alone never clashes.
|
| 68 |
+
const rand = Math.random().toString(36).slice(2, 8)
|
| 69 |
+
return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Write atomically: stream to {target}.part, then rename. */
|
| 73 |
+
async write(target: string, data: Buffer): Promise<string> {
|
| 74 |
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
| 75 |
+
const tmp = `${target}.${process.pid}.${Date.now()}.part`
|
| 76 |
+
await fs.writeFile(tmp, data)
|
| 77 |
+
await fs.rename(tmp, target)
|
| 78 |
+
return target
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/** Remove files older than retentionMs. Returns summary. */
|
| 82 |
+
async gc(): Promise<{ removed: number; bytes: number }> {
|
| 83 |
+
let removed = 0
|
| 84 |
+
let bytes = 0
|
| 85 |
+
const now = Date.now()
|
| 86 |
+
|
| 87 |
+
const walk = async (dir: string): Promise<void> => {
|
| 88 |
+
let entries: Dirent<string>[]
|
| 89 |
+
try {
|
| 90 |
+
// Pass encoding explicitly so Dirent stays string-typed under
|
| 91 |
+
// newer @types/node where the Buffer overload becomes the default.
|
| 92 |
+
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
|
| 93 |
+
} catch {
|
| 94 |
+
return
|
| 95 |
+
}
|
| 96 |
+
for (const entry of entries) {
|
| 97 |
+
const full = path.join(dir, entry.name)
|
| 98 |
+
if (entry.isDirectory()) {
|
| 99 |
+
await walk(full)
|
| 100 |
+
} else if (entry.isFile()) {
|
| 101 |
+
try {
|
| 102 |
+
const stat = await fs.stat(full)
|
| 103 |
+
const age = now - stat.mtimeMs
|
| 104 |
+
const isOrphanPart = entry.name.endsWith('.part')
|
| 105 |
+
const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
|
| 106 |
+
if (age > threshold) {
|
| 107 |
+
bytes += stat.size
|
| 108 |
+
await fs.unlink(full)
|
| 109 |
+
removed++
|
| 110 |
+
}
|
| 111 |
+
} catch {
|
| 112 |
+
// ignore races
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
await walk(this.root).catch(() => {})
|
| 119 |
+
return { removed, bytes }
|
| 120 |
+
}
|
| 121 |
+
}
|
adapters/common/attachment/attachment-types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared attachment types for IM adapters.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import type { AttachmentRef } from '../ws-bridge.js'
|
| 6 |
+
export type { AttachmentRef }
|
| 7 |
+
|
| 8 |
+
/** Platform tag — used for local staging subdir and telemetry. */
|
| 9 |
+
export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk'
|
| 10 |
+
|
| 11 |
+
/** Result of downloading an IM resource into the local stage dir. */
|
| 12 |
+
export interface LocalAttachment {
|
| 13 |
+
kind: 'image' | 'file'
|
| 14 |
+
name: string // original filename, or synthesized if none
|
| 15 |
+
path: string // absolute path on disk (under ~/.claude/im-downloads)
|
| 16 |
+
size: number // bytes
|
| 17 |
+
mimeType: string // detected or provided
|
| 18 |
+
buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** Pending outbound media found in Agent stream output. */
|
| 22 |
+
export interface PendingUpload {
|
| 23 |
+
id: string // fingerprint, used for dedup
|
| 24 |
+
source:
|
| 25 |
+
| { kind: 'base64'; data: string; mime: string }
|
| 26 |
+
| { kind: 'path'; path: string; mime?: string }
|
| 27 |
+
| { kind: 'url'; url: string; mime?: string }
|
| 28 |
+
alt?: string
|
| 29 |
+
}
|
adapters/common/attachment/image-block-watcher.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pure, stateful extractor that watches a stream of assistant text for
|
| 3 |
+
* markdown image references (``) 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: 
|
| 15 |
+
// `alt` may be empty; `target` stops at the first closing paren.
|
| 16 |
+
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
|
| 17 |
+
|
| 18 |
+
function fingerprint(raw: string): string {
|
| 19 |
+
let h = 5381
|
| 20 |
+
for (let i = 0; i < raw.length; i++) {
|
| 21 |
+
h = ((h << 5) + h) ^ raw.charCodeAt(i)
|
| 22 |
+
}
|
| 23 |
+
return (h >>> 0).toString(16)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function classify(target: string): PendingUpload['source'] | null {
|
| 27 |
+
if (target.startsWith('data:')) {
|
| 28 |
+
const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
|
| 29 |
+
if (!m) return null
|
| 30 |
+
return { kind: 'base64', mime: m[1]!, data: m[2]! }
|
| 31 |
+
}
|
| 32 |
+
if (target.startsWith('file://')) {
|
| 33 |
+
return { kind: 'path', path: target.slice('file://'.length) }
|
| 34 |
+
}
|
| 35 |
+
if (target.startsWith('http://') || target.startsWith('https://')) {
|
| 36 |
+
return { kind: 'url', url: target }
|
| 37 |
+
}
|
| 38 |
+
if (target.startsWith('/')) {
|
| 39 |
+
return { kind: 'path', path: target }
|
| 40 |
+
}
|
| 41 |
+
return null // relative paths — skip, we can't resolve them safely
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export class ImageBlockWatcher {
|
| 45 |
+
private buffer = ''
|
| 46 |
+
private seen = new Set<string>()
|
| 47 |
+
private accumulated: PendingUpload[] = []
|
| 48 |
+
|
| 49 |
+
/** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
|
| 50 |
+
feed(chunk: string): PendingUpload[] {
|
| 51 |
+
this.buffer += chunk
|
| 52 |
+
const out: PendingUpload[] = []
|
| 53 |
+
|
| 54 |
+
IMAGE_RE.lastIndex = 0
|
| 55 |
+
let lastConsumedEnd = 0
|
| 56 |
+
let m: RegExpExecArray | null
|
| 57 |
+
while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
|
| 58 |
+
const [, alt, target] = m
|
| 59 |
+
const source = classify(target!)
|
| 60 |
+
if (source) {
|
| 61 |
+
const id = fingerprint(`${source.kind}:${target}`)
|
| 62 |
+
if (!this.seen.has(id)) {
|
| 63 |
+
this.seen.add(id)
|
| 64 |
+
const pending: PendingUpload = { id, source, alt: alt || undefined }
|
| 65 |
+
out.push(pending)
|
| 66 |
+
this.accumulated.push(pending)
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
lastConsumedEnd = m.index + m[0].length
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Preserve tail that might contain a partially-received marker.
|
| 73 |
+
if (lastConsumedEnd > 0) {
|
| 74 |
+
this.buffer = this.buffer.slice(lastConsumedEnd)
|
| 75 |
+
}
|
| 76 |
+
if (this.buffer.length > 4096) {
|
| 77 |
+
this.buffer = this.buffer.slice(-2048)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return out
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Return everything seen so far (for end-of-stream reconciliation). */
|
| 84 |
+
drain(): PendingUpload[] {
|
| 85 |
+
return [...this.accumulated]
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/** Reset watcher state (use at /clear or new session). */
|
| 89 |
+
reset(): void {
|
| 90 |
+
this.buffer = ''
|
| 91 |
+
this.seen.clear()
|
| 92 |
+
this.accumulated = []
|
| 93 |
+
}
|
| 94 |
+
}
|
adapters/common/chat-queue.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 会话串行队列
|
| 3 |
+
*
|
| 4 |
+
* 同一 chatId 的消息串行处理,防并发冲突。
|
| 5 |
+
* 不同 chatId 之间互不影响。
|
| 6 |
+
* 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
const queues = new Map<string, Promise<void>>()
|
| 10 |
+
|
| 11 |
+
export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
|
| 12 |
+
const prev = queues.get(chatId) ?? Promise.resolve()
|
| 13 |
+
const next = prev.then(fn, () => fn()).catch((err) => {
|
| 14 |
+
console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
|
| 15 |
+
})
|
| 16 |
+
queues.set(chatId, next)
|
| 17 |
+
// Clean up after completion to avoid memory leak for one-off chats
|
| 18 |
+
next.finally(() => {
|
| 19 |
+
if (queues.get(chatId) === next) {
|
| 20 |
+
queues.delete(chatId)
|
| 21 |
+
}
|
| 22 |
+
})
|
| 23 |
+
return next
|
| 24 |
+
}
|
adapters/common/config.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Adapter 配置加载
|
| 3 |
+
*
|
| 4 |
+
* 优先级:环境变量 > ~/.claude/adapters.json > 默认值
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import * as fs from 'node:fs'
|
| 8 |
+
import * as os from 'node:os'
|
| 9 |
+
import * as path from 'node:path'
|
| 10 |
+
|
| 11 |
+
export type PairedUser = {
|
| 12 |
+
userId: string | number
|
| 13 |
+
displayName: string
|
| 14 |
+
pairedAt: number
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export type PairingState = {
|
| 18 |
+
code: string | null
|
| 19 |
+
expiresAt: number | null
|
| 20 |
+
createdAt: number | null
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type TelegramConfig = {
|
| 24 |
+
botToken: string
|
| 25 |
+
allowedUsers: number[]
|
| 26 |
+
pairedUsers: PairedUser[]
|
| 27 |
+
defaultWorkDir: string
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export type FeishuConfig = {
|
| 31 |
+
appId: string
|
| 32 |
+
appSecret: string
|
| 33 |
+
encryptKey: string
|
| 34 |
+
verificationToken: string
|
| 35 |
+
allowedUsers: string[]
|
| 36 |
+
pairedUsers: PairedUser[]
|
| 37 |
+
defaultWorkDir: string
|
| 38 |
+
streamingCard: boolean
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export type WechatConfig = {
|
| 42 |
+
accountId: string
|
| 43 |
+
botToken: string
|
| 44 |
+
baseUrl: string
|
| 45 |
+
userId: string
|
| 46 |
+
allowedUsers: string[]
|
| 47 |
+
pairedUsers: PairedUser[]
|
| 48 |
+
defaultWorkDir: string
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export type DingtalkConfig = {
|
| 52 |
+
clientId: string
|
| 53 |
+
clientSecret: string
|
| 54 |
+
allowedUsers: string[]
|
| 55 |
+
pairedUsers: PairedUser[]
|
| 56 |
+
defaultWorkDir: string
|
| 57 |
+
endpoint: string
|
| 58 |
+
permissionCardTemplateId: string
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export type AdapterConfig = {
|
| 62 |
+
serverUrl: string
|
| 63 |
+
defaultProjectDir: string
|
| 64 |
+
pairing: PairingState
|
| 65 |
+
telegram: TelegramConfig
|
| 66 |
+
feishu: FeishuConfig
|
| 67 |
+
wechat: WechatConfig
|
| 68 |
+
dingtalk: DingtalkConfig
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export type AdapterPlatformConfig =
|
| 72 |
+
| TelegramConfig
|
| 73 |
+
| FeishuConfig
|
| 74 |
+
| WechatConfig
|
| 75 |
+
| DingtalkConfig
|
| 76 |
+
|
| 77 |
+
function getConfigPath(): string {
|
| 78 |
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
| 79 |
+
return path.join(configDir, 'adapters.json')
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function loadFile(): Record<string, any> {
|
| 83 |
+
try {
|
| 84 |
+
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
| 85 |
+
} catch (err: any) {
|
| 86 |
+
if (err?.code !== 'ENOENT') {
|
| 87 |
+
console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
|
| 88 |
+
}
|
| 89 |
+
return {}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export function loadConfig(): AdapterConfig {
|
| 94 |
+
const file = loadFile()
|
| 95 |
+
const tg = file.telegram ?? {}
|
| 96 |
+
const fs_ = file.feishu ?? {}
|
| 97 |
+
const wc = file.wechat ?? {}
|
| 98 |
+
const dt = file.dingtalk ?? {}
|
| 99 |
+
const pairing = file.pairing ?? {}
|
| 100 |
+
const fallbackWorkDir = resolveUserDefaultWorkDir()
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
|
| 104 |
+
defaultProjectDir: file.defaultProjectDir || '',
|
| 105 |
+
pairing: {
|
| 106 |
+
code: pairing.code ?? null,
|
| 107 |
+
expiresAt: pairing.expiresAt ?? null,
|
| 108 |
+
createdAt: pairing.createdAt ?? null,
|
| 109 |
+
},
|
| 110 |
+
telegram: {
|
| 111 |
+
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
|
| 112 |
+
allowedUsers: tg.allowedUsers ?? [],
|
| 113 |
+
pairedUsers: tg.pairedUsers ?? [],
|
| 114 |
+
defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir,
|
| 115 |
+
},
|
| 116 |
+
feishu: {
|
| 117 |
+
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
|
| 118 |
+
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
|
| 119 |
+
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
|
| 120 |
+
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
|
| 121 |
+
allowedUsers: fs_.allowedUsers ?? [],
|
| 122 |
+
pairedUsers: fs_.pairedUsers ?? [],
|
| 123 |
+
defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir,
|
| 124 |
+
streamingCard: fs_.streamingCard ?? false,
|
| 125 |
+
},
|
| 126 |
+
wechat: {
|
| 127 |
+
accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '',
|
| 128 |
+
botToken: process.env.WECHAT_BOT_TOKEN || wc.botToken || '',
|
| 129 |
+
baseUrl: process.env.WECHAT_BASE_URL || wc.baseUrl || 'https://ilinkai.weixin.qq.com',
|
| 130 |
+
userId: process.env.WECHAT_USER_ID || wc.userId || '',
|
| 131 |
+
allowedUsers: wc.allowedUsers ?? [],
|
| 132 |
+
pairedUsers: wc.pairedUsers ?? [],
|
| 133 |
+
defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir,
|
| 134 |
+
},
|
| 135 |
+
dingtalk: {
|
| 136 |
+
clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '',
|
| 137 |
+
clientSecret: process.env.DINGTALK_CLIENT_SECRET || dt.clientSecret || '',
|
| 138 |
+
allowedUsers: dt.allowedUsers ?? [],
|
| 139 |
+
pairedUsers: dt.pairedUsers ?? [],
|
| 140 |
+
defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir,
|
| 141 |
+
endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com',
|
| 142 |
+
permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '',
|
| 143 |
+
},
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: AdapterPlatformConfig): string {
|
| 148 |
+
return config.defaultProjectDir || platformConfig.defaultWorkDir
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function resolveUserDefaultWorkDir(): string {
|
| 152 |
+
const candidates = [
|
| 153 |
+
process.env.ADAPTER_DEFAULT_PROJECT_DIR,
|
| 154 |
+
process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
|
| 155 |
+
process.env.PWD,
|
| 156 |
+
process.cwd(),
|
| 157 |
+
os.homedir(),
|
| 158 |
+
]
|
| 159 |
+
|
| 160 |
+
for (const candidate of candidates) {
|
| 161 |
+
const resolved = resolveExistingDirectory(candidate)
|
| 162 |
+
if (resolved) return resolved
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
return os.homedir()
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function resolveExistingDirectory(value: string | undefined): string | null {
|
| 169 |
+
const trimmed = value?.trim()
|
| 170 |
+
if (!trimmed) return null
|
| 171 |
+
|
| 172 |
+
const expanded = trimmed === '~'
|
| 173 |
+
? os.homedir()
|
| 174 |
+
: trimmed.startsWith('~/')
|
| 175 |
+
? path.join(os.homedir(), trimmed.slice(2))
|
| 176 |
+
: trimmed
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
const realPath = fs.realpathSync(expanded)
|
| 180 |
+
return fs.statSync(realPath).isDirectory() ? realPath : null
|
| 181 |
+
} catch {
|
| 182 |
+
return null
|
| 183 |
+
}
|
| 184 |
+
}
|
adapters/common/format.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 消息格式化工具
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
type AdapterChatState =
|
| 6 |
+
| 'idle'
|
| 7 |
+
| 'thinking'
|
| 8 |
+
| 'streaming'
|
| 9 |
+
| 'tool_executing'
|
| 10 |
+
| 'permission_pending'
|
| 11 |
+
|
| 12 |
+
type ImStatusSummary = {
|
| 13 |
+
sessionId?: string
|
| 14 |
+
projectName?: string | null
|
| 15 |
+
branch?: string | null
|
| 16 |
+
model?: string | null
|
| 17 |
+
state?: AdapterChatState | null
|
| 18 |
+
verb?: string | null
|
| 19 |
+
pendingPermissionCount?: number
|
| 20 |
+
taskCounts?: {
|
| 21 |
+
total: number
|
| 22 |
+
pending: number
|
| 23 |
+
inProgress: number
|
| 24 |
+
completed: number
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const IM_HELP_LINES = [
|
| 29 |
+
'/new [项目] / 新会话 — 新建会话或切换项目',
|
| 30 |
+
'/projects / 项目列表 — 查看最近项目',
|
| 31 |
+
'/status / 状态 — 查看当前会话状态',
|
| 32 |
+
'/clear / 清空 — 清空当前会话上下文',
|
| 33 |
+
'/stop / 停止 — 停止当前生成',
|
| 34 |
+
'/help / 帮助 — 显示这份帮助',
|
| 35 |
+
'权限审批:/allow <id>、/always <id>、/deny <id>',
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
|
| 39 |
+
export function splitMessage(text: string, limit: number): string[] {
|
| 40 |
+
if (text.length <= limit) return [text]
|
| 41 |
+
|
| 42 |
+
const chunks: string[] = []
|
| 43 |
+
let remaining = text
|
| 44 |
+
|
| 45 |
+
while (remaining.length > 0) {
|
| 46 |
+
if (remaining.length <= limit) {
|
| 47 |
+
chunks.push(remaining)
|
| 48 |
+
break
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
let splitAt = remaining.lastIndexOf('\n\n', limit)
|
| 52 |
+
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
|
| 53 |
+
if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
|
| 54 |
+
if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
|
| 55 |
+
if (splitAt <= 0) splitAt = limit
|
| 56 |
+
|
| 57 |
+
// Include the delimiter for paragraph/sentence breaks
|
| 58 |
+
if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
|
| 59 |
+
|
| 60 |
+
chunks.push(remaining.slice(0, splitAt).trimEnd())
|
| 61 |
+
remaining = remaining.slice(splitAt).trimStart()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return chunks
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
type MarkdownTable = {
|
| 68 |
+
headers: string[]
|
| 69 |
+
rows: string[][]
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function splitMarkdownTableRow(line: string): string[] {
|
| 73 |
+
const trimmed = line.trim()
|
| 74 |
+
const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
|
| 75 |
+
const withoutTrailingPipe = inner.endsWith('|') ? inner.slice(0, -1) : inner
|
| 76 |
+
return withoutTrailingPipe.split('|').map((cell) => cell.trim())
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function isMarkdownTableDivider(line: string): boolean {
|
| 80 |
+
const cells = splitMarkdownTableRow(line)
|
| 81 |
+
if (cells.length < 2) return false
|
| 82 |
+
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function isPotentialMarkdownTableRow(line: string): boolean {
|
| 86 |
+
const trimmed = line.trim()
|
| 87 |
+
return trimmed.includes('|') && splitMarkdownTableRow(trimmed).length >= 2
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function isFenceMarker(line: string): boolean {
|
| 91 |
+
return /^\s*(```|~~~)/.test(line)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function formatMarkdownTableAsBullets(table: MarkdownTable): string {
|
| 95 |
+
const { headers, rows } = table
|
| 96 |
+
if (headers.length === 0 || rows.length === 0) return ''
|
| 97 |
+
|
| 98 |
+
const output: string[] = []
|
| 99 |
+
|
| 100 |
+
for (const row of rows) {
|
| 101 |
+
if (row.every((cell) => !cell)) continue
|
| 102 |
+
|
| 103 |
+
const label = row[0]
|
| 104 |
+
if (label) output.push(label)
|
| 105 |
+
|
| 106 |
+
for (let i = 1; i < Math.max(headers.length, row.length); i++) {
|
| 107 |
+
const value = row[i]
|
| 108 |
+
if (!value) continue
|
| 109 |
+
const header = headers[i]
|
| 110 |
+
output.push(`• ${header ? `${header}: ` : `Column ${i}: `}${value}`)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (output[output.length - 1] !== '') output.push('')
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
while (output[output.length - 1] === '') output.pop()
|
| 117 |
+
return output.join('\n')
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/** Convert GitHub-flavored Markdown pipe tables into mobile-friendly bullet lists. */
|
| 121 |
+
export function convertMarkdownTablesToBullets(markdown: string): string {
|
| 122 |
+
const lines = markdown.split('\n')
|
| 123 |
+
const output: string[] = []
|
| 124 |
+
let inFence = false
|
| 125 |
+
let i = 0
|
| 126 |
+
|
| 127 |
+
while (i < lines.length) {
|
| 128 |
+
const headerLine = lines[i] ?? ''
|
| 129 |
+
|
| 130 |
+
if (isFenceMarker(headerLine)) {
|
| 131 |
+
inFence = !inFence
|
| 132 |
+
output.push(headerLine)
|
| 133 |
+
i += 1
|
| 134 |
+
continue
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const dividerLine = lines[i + 1] ?? ''
|
| 138 |
+
if (!inFence && isPotentialMarkdownTableRow(headerLine) && isMarkdownTableDivider(dividerLine)) {
|
| 139 |
+
const headers = splitMarkdownTableRow(headerLine)
|
| 140 |
+
const rows: string[][] = []
|
| 141 |
+
i += 2
|
| 142 |
+
|
| 143 |
+
while (i < lines.length && isPotentialMarkdownTableRow(lines[i] ?? '')) {
|
| 144 |
+
rows.push(splitMarkdownTableRow(lines[i] ?? ''))
|
| 145 |
+
i += 1
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const rendered = formatMarkdownTableAsBullets({ headers, rows })
|
| 149 |
+
if (rendered) output.push(rendered)
|
| 150 |
+
continue
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
output.push(headerLine)
|
| 154 |
+
i += 1
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
return output.join('\n')
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/** Format tool use info for display in IM. */
|
| 161 |
+
export function formatToolUse(toolName: string, input: unknown): string {
|
| 162 |
+
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
|
| 163 |
+
const summary = formatToolSummary(toolName, inp)
|
| 164 |
+
if (summary) return `🔧 ${toolName} ${summary}`
|
| 165 |
+
const preview = truncateInput(input, 200)
|
| 166 |
+
return `🔧 ${toolName}\n${preview}`
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/** Generate a concise human-readable summary for common tools. */
|
| 170 |
+
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
|
| 171 |
+
switch (tool) {
|
| 172 |
+
case 'Bash': {
|
| 173 |
+
const desc = inp.description as string | undefined
|
| 174 |
+
const cmd = inp.command as string | undefined
|
| 175 |
+
if (desc) return desc
|
| 176 |
+
if (cmd) return truncate(cmd, 120)
|
| 177 |
+
return null
|
| 178 |
+
}
|
| 179 |
+
case 'Read': {
|
| 180 |
+
const fp = inp.file_path as string | undefined
|
| 181 |
+
if (fp) return shortPath(fp)
|
| 182 |
+
return null
|
| 183 |
+
}
|
| 184 |
+
case 'Edit': {
|
| 185 |
+
const fp = inp.file_path as string | undefined
|
| 186 |
+
if (fp) return shortPath(fp)
|
| 187 |
+
return null
|
| 188 |
+
}
|
| 189 |
+
case 'Write': {
|
| 190 |
+
const fp = inp.file_path as string | undefined
|
| 191 |
+
if (fp) return shortPath(fp)
|
| 192 |
+
return null
|
| 193 |
+
}
|
| 194 |
+
case 'Grep': {
|
| 195 |
+
const pat = inp.pattern as string | undefined
|
| 196 |
+
const p = inp.path as string | undefined
|
| 197 |
+
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
|
| 198 |
+
return null
|
| 199 |
+
}
|
| 200 |
+
case 'Glob': {
|
| 201 |
+
const pat = inp.pattern as string | undefined
|
| 202 |
+
return pat ? `"${pat}"` : null
|
| 203 |
+
}
|
| 204 |
+
case 'Skill': {
|
| 205 |
+
const skill = inp.skill as string | undefined
|
| 206 |
+
return skill || null
|
| 207 |
+
}
|
| 208 |
+
case 'Agent': {
|
| 209 |
+
const desc = inp.description as string | undefined
|
| 210 |
+
return desc || null
|
| 211 |
+
}
|
| 212 |
+
case 'WebFetch': {
|
| 213 |
+
const url = inp.url as string | undefined
|
| 214 |
+
return url ? truncate(url, 120) : null
|
| 215 |
+
}
|
| 216 |
+
case 'WebSearch': {
|
| 217 |
+
const q = inp.query as string | undefined
|
| 218 |
+
return q ? `"${truncate(q, 80)}"` : null
|
| 219 |
+
}
|
| 220 |
+
default:
|
| 221 |
+
return null
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function shortPath(fp: string): string {
|
| 226 |
+
const parts = fp.split('/')
|
| 227 |
+
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function truncate(s: string, max: number): string {
|
| 231 |
+
return s.length > max ? s.slice(0, max) + '…' : s
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/** Format a permission request for display in IM. */
|
| 235 |
+
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
|
| 236 |
+
const preview = truncateInput(input, 300)
|
| 237 |
+
return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/** Truncate tool input to a preview string. */
|
| 241 |
+
export function truncateInput(input: unknown, maxLen: number): string {
|
| 242 |
+
try {
|
| 243 |
+
const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
| 244 |
+
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
|
| 245 |
+
} catch {
|
| 246 |
+
return '(unserializable)'
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/** Escape special characters for Telegram MarkdownV2. */
|
| 251 |
+
export function escapeMarkdownV2(text: string): string {
|
| 252 |
+
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
export function formatImHelp(): string {
|
| 256 |
+
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
export function formatImStatus(summary: ImStatusSummary | null): string {
|
| 260 |
+
if (!summary?.sessionId) {
|
| 261 |
+
return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
const lines = ['当前会话状态:']
|
| 265 |
+
|
| 266 |
+
if (summary.projectName) {
|
| 267 |
+
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
|
| 268 |
+
} else if (summary.branch) {
|
| 269 |
+
lines.push(`分支: ${summary.branch}`)
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
|
| 273 |
+
|
| 274 |
+
if (summary.model) {
|
| 275 |
+
lines.push(`模型: ${summary.model}`)
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
|
| 279 |
+
|
| 280 |
+
const pendingPermissionCount = summary.pendingPermissionCount ?? 0
|
| 281 |
+
if (pendingPermissionCount > 0) {
|
| 282 |
+
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const taskCounts = summary.taskCounts
|
| 286 |
+
if (taskCounts && taskCounts.total > 0) {
|
| 287 |
+
const taskParts = [`总计 ${taskCounts.total}`]
|
| 288 |
+
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
|
| 289 |
+
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
|
| 290 |
+
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
|
| 291 |
+
lines.push(`任务: ${taskParts.join(' · ')}`)
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
return lines.join('\n')
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function formatAdapterChatState(
|
| 298 |
+
state: AdapterChatState | null | undefined,
|
| 299 |
+
verb: string | null | undefined,
|
| 300 |
+
): string {
|
| 301 |
+
const label = (() => {
|
| 302 |
+
switch (state) {
|
| 303 |
+
case 'thinking':
|
| 304 |
+
return '思考中'
|
| 305 |
+
case 'streaming':
|
| 306 |
+
return '生成中'
|
| 307 |
+
case 'tool_executing':
|
| 308 |
+
return '执行工具中'
|
| 309 |
+
case 'permission_pending':
|
| 310 |
+
return '等待权限确认'
|
| 311 |
+
case 'idle':
|
| 312 |
+
default:
|
| 313 |
+
return '空闲'
|
| 314 |
+
}
|
| 315 |
+
})()
|
| 316 |
+
|
| 317 |
+
if (!verb || verb === 'Thinking') return label
|
| 318 |
+
return `${label} (${verb})`
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function shortSessionId(sessionId: string): string {
|
| 322 |
+
return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
|
| 323 |
+
}
|
adapters/common/http-client.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as fs from 'node:fs'
|
| 2 |
+
import * as os from 'node:os'
|
| 3 |
+
import * as path from 'node:path'
|
| 4 |
+
|
| 5 |
+
export type RecentProject = {
|
| 6 |
+
projectPath: string
|
| 7 |
+
realPath: string
|
| 8 |
+
projectName: string
|
| 9 |
+
isGit: boolean
|
| 10 |
+
repoName: string | null
|
| 11 |
+
branch: string | null
|
| 12 |
+
modifiedAt: string
|
| 13 |
+
sessionCount: number
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export type GitInfo = {
|
| 17 |
+
branch: string | null
|
| 18 |
+
repoName: string | null
|
| 19 |
+
workDir: string
|
| 20 |
+
changedFiles: number
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type SessionTask = {
|
| 24 |
+
id: string
|
| 25 |
+
subject: string
|
| 26 |
+
status: 'pending' | 'in_progress' | 'completed'
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export class AdapterHttpClient {
|
| 30 |
+
readonly httpBaseUrl: string
|
| 31 |
+
private readonly allowedProjectRoots: string[]
|
| 32 |
+
/** Default timeout for HTTP requests (30 seconds) */
|
| 33 |
+
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
| 34 |
+
|
| 35 |
+
constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
|
| 36 |
+
this.httpBaseUrl = wsUrl
|
| 37 |
+
.replace(/^ws:/, 'http:')
|
| 38 |
+
.replace(/^wss:/, 'https:')
|
| 39 |
+
.replace(/\/$/, '')
|
| 40 |
+
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
|
| 41 |
+
.map(resolveExistingProjectPath)
|
| 42 |
+
.filter((value): value is string => Boolean(value))
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Create an AbortController with timeout */
|
| 46 |
+
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
|
| 47 |
+
controller: AbortController
|
| 48 |
+
timer: ReturnType<typeof setTimeout>
|
| 49 |
+
} {
|
| 50 |
+
const controller = new AbortController()
|
| 51 |
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
| 52 |
+
return { controller, timer }
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async createSession(workDir: string): Promise<string> {
|
| 56 |
+
const { controller, timer } = this.createTimeoutController()
|
| 57 |
+
try {
|
| 58 |
+
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
| 59 |
+
method: 'POST',
|
| 60 |
+
headers: { 'Content-Type': 'application/json' },
|
| 61 |
+
body: JSON.stringify({ workDir }),
|
| 62 |
+
signal: controller.signal,
|
| 63 |
+
})
|
| 64 |
+
if (!res.ok) {
|
| 65 |
+
const err = await res.json().catch(() => ({ message: res.statusText }))
|
| 66 |
+
throw new Error(`Failed to create session: ${(err as any).message}`)
|
| 67 |
+
}
|
| 68 |
+
const data = (await res.json()) as { sessionId: string }
|
| 69 |
+
return data.sessionId
|
| 70 |
+
} finally {
|
| 71 |
+
clearTimeout(timer)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async listRecentProjects(): Promise<RecentProject[]> {
|
| 76 |
+
const { controller, timer } = this.createTimeoutController()
|
| 77 |
+
try {
|
| 78 |
+
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
| 79 |
+
signal: controller.signal,
|
| 80 |
+
})
|
| 81 |
+
if (!res.ok) {
|
| 82 |
+
throw new Error(`Failed to list projects: ${res.statusText}`)
|
| 83 |
+
}
|
| 84 |
+
const data = (await res.json()) as { projects: RecentProject[] }
|
| 85 |
+
return data.projects
|
| 86 |
+
} finally {
|
| 87 |
+
clearTimeout(timer)
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Match a project by index (1-based) or fuzzy name from recent projects.
|
| 93 |
+
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
| 94 |
+
*/
|
| 95 |
+
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
| 96 |
+
const directPath = resolveExistingProjectPath(query)
|
| 97 |
+
if (directPath) {
|
| 98 |
+
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
|
| 99 |
+
return {}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
project: {
|
| 104 |
+
projectPath: directPath,
|
| 105 |
+
realPath: directPath,
|
| 106 |
+
projectName: path.basename(directPath) || directPath,
|
| 107 |
+
isGit: fs.existsSync(path.join(directPath, '.git')),
|
| 108 |
+
repoName: null,
|
| 109 |
+
branch: null,
|
| 110 |
+
modifiedAt: new Date().toISOString(),
|
| 111 |
+
sessionCount: 0,
|
| 112 |
+
},
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const projects = await this.listRecentProjects()
|
| 117 |
+
|
| 118 |
+
// Try as 1-based index
|
| 119 |
+
const num = parseInt(query, 10)
|
| 120 |
+
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
| 121 |
+
return { project: projects[num - 1] }
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const q = query.toLowerCase()
|
| 125 |
+
|
| 126 |
+
// Exact project name match
|
| 127 |
+
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
| 128 |
+
if (exact) return { project: exact }
|
| 129 |
+
|
| 130 |
+
// Fuzzy: name or path contains query
|
| 131 |
+
const matches = projects.filter(p =>
|
| 132 |
+
p.projectName.toLowerCase().includes(q) ||
|
| 133 |
+
p.realPath.toLowerCase().includes(q)
|
| 134 |
+
)
|
| 135 |
+
if (matches.length === 1) return { project: matches[0] }
|
| 136 |
+
if (matches.length > 1) return { ambiguous: matches }
|
| 137 |
+
|
| 138 |
+
return {}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
| 142 |
+
const { controller, timer } = this.createTimeoutController()
|
| 143 |
+
try {
|
| 144 |
+
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
| 145 |
+
signal: controller.signal,
|
| 146 |
+
})
|
| 147 |
+
if (!res.ok) {
|
| 148 |
+
const err = await res.json().catch(() => ({ message: res.statusText }))
|
| 149 |
+
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
| 150 |
+
}
|
| 151 |
+
return (await res.json()) as GitInfo
|
| 152 |
+
} finally {
|
| 153 |
+
clearTimeout(timer)
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
| 158 |
+
const { controller, timer } = this.createTimeoutController()
|
| 159 |
+
try {
|
| 160 |
+
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
| 161 |
+
signal: controller.signal,
|
| 162 |
+
})
|
| 163 |
+
if (!res.ok) {
|
| 164 |
+
if (res.status === 404) return []
|
| 165 |
+
const err = await res.json().catch(() => ({ message: res.statusText }))
|
| 166 |
+
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
| 167 |
+
}
|
| 168 |
+
const data = (await res.json()) as { tasks?: SessionTask[] }
|
| 169 |
+
return Array.isArray(data.tasks) ? data.tasks : []
|
| 170 |
+
} finally {
|
| 171 |
+
clearTimeout(timer)
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
|
| 177 |
+
if (roots.length === 0) return false
|
| 178 |
+
|
| 179 |
+
for (const root of roots) {
|
| 180 |
+
const relative = path.relative(root, target)
|
| 181 |
+
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
| 182 |
+
return true
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
return false
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function resolveExistingProjectPath(query: string): string | null {
|
| 190 |
+
const trimmed = query.trim()
|
| 191 |
+
if (!trimmed) return null
|
| 192 |
+
|
| 193 |
+
const expanded = trimmed === '~'
|
| 194 |
+
? os.homedir()
|
| 195 |
+
: trimmed.startsWith('~/')
|
| 196 |
+
? path.join(os.homedir(), trimmed.slice(2))
|
| 197 |
+
: trimmed
|
| 198 |
+
|
| 199 |
+
if (!path.isAbsolute(expanded)) return null
|
| 200 |
+
|
| 201 |
+
try {
|
| 202 |
+
const realPath = fs.realpathSync(expanded)
|
| 203 |
+
return fs.statSync(realPath).isDirectory() ? realPath : null
|
| 204 |
+
} catch {
|
| 205 |
+
return null
|
| 206 |
+
}
|
| 207 |
+
}
|
adapters/common/message-buffer.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 流式消息缓冲
|
| 3 |
+
*
|
| 4 |
+
* 将 content_delta 累积后按时间窗口或字符数批量 flush。
|
| 5 |
+
* 用于 Telegram editMessage / 飞书流式卡片更新。
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
|
| 9 |
+
|
| 10 |
+
const DEFAULT_INTERVAL_MS = 500
|
| 11 |
+
const DEFAULT_CHAR_THRESHOLD = 200
|
| 12 |
+
|
| 13 |
+
export class MessageBuffer {
|
| 14 |
+
private buffer = ''
|
| 15 |
+
private timer: ReturnType<typeof setTimeout> | null = null
|
| 16 |
+
private flushing = false
|
| 17 |
+
private pendingComplete = false
|
| 18 |
+
private activeFlush: Promise<void> | null = null
|
| 19 |
+
|
| 20 |
+
constructor(
|
| 21 |
+
private onFlush: FlushCallback,
|
| 22 |
+
private intervalMs = DEFAULT_INTERVAL_MS,
|
| 23 |
+
private charThreshold = DEFAULT_CHAR_THRESHOLD,
|
| 24 |
+
) {}
|
| 25 |
+
|
| 26 |
+
/** Append text delta. Triggers flush if threshold reached. */
|
| 27 |
+
append(text: string): void {
|
| 28 |
+
this.buffer += text
|
| 29 |
+
if (this.buffer.length >= this.charThreshold) {
|
| 30 |
+
this.scheduleFlush()
|
| 31 |
+
} else if (!this.timer) {
|
| 32 |
+
this.timer = setTimeout(() => this.flush(false), this.intervalMs)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** Immediately flush all remaining content (called on message_complete). */
|
| 37 |
+
async complete(): Promise<void> {
|
| 38 |
+
if (this.timer) {
|
| 39 |
+
clearTimeout(this.timer)
|
| 40 |
+
this.timer = null
|
| 41 |
+
}
|
| 42 |
+
if (this.flushing) {
|
| 43 |
+
// A flush is in-flight; mark pending so it fires after current flush finishes
|
| 44 |
+
this.pendingComplete = true
|
| 45 |
+
await this.activeFlush
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
await this.flush(true)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Reset the buffer for a new message. */
|
| 52 |
+
reset(): void {
|
| 53 |
+
this.buffer = ''
|
| 54 |
+
this.pendingComplete = false
|
| 55 |
+
if (this.timer) {
|
| 56 |
+
clearTimeout(this.timer)
|
| 57 |
+
this.timer = null
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
private scheduleFlush(): void {
|
| 62 |
+
if (this.timer) {
|
| 63 |
+
clearTimeout(this.timer)
|
| 64 |
+
this.timer = null
|
| 65 |
+
}
|
| 66 |
+
queueMicrotask(() => this.flush(false))
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
private async flush(isComplete: boolean): Promise<void> {
|
| 70 |
+
if (this.timer) {
|
| 71 |
+
clearTimeout(this.timer)
|
| 72 |
+
this.timer = null
|
| 73 |
+
}
|
| 74 |
+
if (this.flushing) {
|
| 75 |
+
await this.activeFlush
|
| 76 |
+
return
|
| 77 |
+
}
|
| 78 |
+
if (this.buffer.length === 0) return
|
| 79 |
+
|
| 80 |
+
this.flushing = true
|
| 81 |
+
const text = this.buffer
|
| 82 |
+
this.buffer = ''
|
| 83 |
+
this.activeFlush = (async () => {
|
| 84 |
+
try {
|
| 85 |
+
await this.onFlush(text, isComplete)
|
| 86 |
+
} catch (err) {
|
| 87 |
+
console.error('[MessageBuffer] Flush error:', err)
|
| 88 |
+
} finally {
|
| 89 |
+
this.flushing = false
|
| 90 |
+
this.activeFlush = null
|
| 91 |
+
// If complete() was called while we were flushing, do the final flush now.
|
| 92 |
+
if (this.pendingComplete) {
|
| 93 |
+
this.pendingComplete = false
|
| 94 |
+
await this.flush(true)
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
})()
|
| 98 |
+
await this.activeFlush
|
| 99 |
+
}
|
| 100 |
+
}
|
adapters/common/message-dedup.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 消息去重
|
| 3 |
+
*
|
| 4 |
+
* 防止 WebSocket 重连等场景下消息重复处理。
|
| 5 |
+
* 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
|
| 9 |
+
const DEFAULT_MAX_ENTRIES = 5000
|
| 10 |
+
const SWEEP_INTERVAL_MS = 60_000 // 1 minute
|
| 11 |
+
|
| 12 |
+
export class MessageDedup {
|
| 13 |
+
private store = new Map<string, number>()
|
| 14 |
+
private sweepTimer: ReturnType<typeof setInterval>
|
| 15 |
+
|
| 16 |
+
constructor(
|
| 17 |
+
private ttlMs = DEFAULT_TTL_MS,
|
| 18 |
+
private maxEntries = DEFAULT_MAX_ENTRIES,
|
| 19 |
+
) {
|
| 20 |
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/** Returns true if this is a NEW message, false if duplicate. */
|
| 24 |
+
tryRecord(id: string): boolean {
|
| 25 |
+
const now = Date.now()
|
| 26 |
+
const existing = this.store.get(id)
|
| 27 |
+
|
| 28 |
+
if (existing !== undefined && now - existing < this.ttlMs) {
|
| 29 |
+
return false // duplicate
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Evict oldest if at capacity
|
| 33 |
+
if (this.store.size >= this.maxEntries) {
|
| 34 |
+
const oldest = this.store.keys().next().value
|
| 35 |
+
if (oldest !== undefined) this.store.delete(oldest)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
this.store.set(id, now)
|
| 39 |
+
return true
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
private sweep(): void {
|
| 43 |
+
const now = Date.now()
|
| 44 |
+
for (const [key, ts] of this.store) {
|
| 45 |
+
if (now - ts >= this.ttlMs) {
|
| 46 |
+
this.store.delete(key)
|
| 47 |
+
} else {
|
| 48 |
+
break // Map preserves insertion order; once fresh, rest is fresh
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
destroy(): void {
|
| 54 |
+
clearInterval(this.sweepTimer)
|
| 55 |
+
this.store.clear()
|
| 56 |
+
}
|
| 57 |
+
}
|
adapters/common/pairing.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 配对核心逻辑
|
| 3 |
+
*
|
| 4 |
+
* - generatePairingCode(): 生成 6 位安全配对码
|
| 5 |
+
* - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
|
| 6 |
+
* - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import * as fs from 'node:fs'
|
| 10 |
+
import * as os from 'node:os'
|
| 11 |
+
import * as path from 'node:path'
|
| 12 |
+
import * as crypto from 'node:crypto'
|
| 13 |
+
import type { PairedUser, PairingState } from './config.js'
|
| 14 |
+
|
| 15 |
+
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
|
| 16 |
+
export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
|
| 17 |
+
|
| 18 |
+
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
|
| 19 |
+
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
| 20 |
+
const RATE_LIMIT_MAX_ATTEMPTS = 5
|
| 21 |
+
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
|
| 22 |
+
|
| 23 |
+
function isRateLimited(userId: string | number): boolean {
|
| 24 |
+
const key = String(userId)
|
| 25 |
+
const record = failedAttempts.get(key)
|
| 26 |
+
if (!record) return false
|
| 27 |
+
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
| 28 |
+
failedAttempts.delete(key)
|
| 29 |
+
return false
|
| 30 |
+
}
|
| 31 |
+
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function recordFailedAttempt(userId: string | number): void {
|
| 35 |
+
const key = String(userId)
|
| 36 |
+
const record = failedAttempts.get(key)
|
| 37 |
+
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
| 38 |
+
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
|
| 39 |
+
} else {
|
| 40 |
+
record.count++
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
const CODE_LENGTH = 6
|
| 44 |
+
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
|
| 45 |
+
|
| 46 |
+
function getConfigPath(): string {
|
| 47 |
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
| 48 |
+
return path.join(configDir, 'adapters.json')
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function readConfigFile(): Record<string, any> {
|
| 52 |
+
try {
|
| 53 |
+
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
| 54 |
+
} catch {
|
| 55 |
+
return {}
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function writeConfigFile(data: Record<string, any>): void {
|
| 60 |
+
const filePath = getConfigPath()
|
| 61 |
+
const dir = path.dirname(filePath)
|
| 62 |
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
| 63 |
+
const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}`
|
| 64 |
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 })
|
| 65 |
+
fs.renameSync(tmp, filePath)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function generatePairingCode(): string {
|
| 69 |
+
let code = ''
|
| 70 |
+
for (let i = 0; i < CODE_LENGTH; i++) {
|
| 71 |
+
code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
|
| 72 |
+
}
|
| 73 |
+
return code
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
|
| 77 |
+
export function isPaired(
|
| 78 |
+
platform: ImPlatform,
|
| 79 |
+
userId: string | number,
|
| 80 |
+
config: Record<string, any>,
|
| 81 |
+
): boolean {
|
| 82 |
+
const platformConfig = config[platform] ?? {}
|
| 83 |
+
const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
|
| 84 |
+
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
| 85 |
+
|
| 86 |
+
// allowedUsers 非空时检查
|
| 87 |
+
if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
|
| 88 |
+
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
|
| 89 |
+
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
|
| 90 |
+
|
| 91 |
+
return pairedUsers.some((p) => String(p.userId) === String(userId))
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* 尝试配对:验证消息文本是否匹配当前有效配对码。
|
| 96 |
+
* 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
|
| 97 |
+
*/
|
| 98 |
+
export function tryPair(
|
| 99 |
+
messageText: string,
|
| 100 |
+
senderInfo: { userId: string | number; displayName: string },
|
| 101 |
+
platform: ImPlatform,
|
| 102 |
+
): boolean {
|
| 103 |
+
const file = readConfigFile()
|
| 104 |
+
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
|
| 105 |
+
|
| 106 |
+
// 速率限制检查
|
| 107 |
+
if (isRateLimited(senderInfo.userId)) return false
|
| 108 |
+
|
| 109 |
+
// 检查配对码是否有效
|
| 110 |
+
if (!pairing.code || !pairing.expiresAt) return false
|
| 111 |
+
if (Date.now() > pairing.expiresAt) return false
|
| 112 |
+
|
| 113 |
+
// 比较(忽略大小写和空格)
|
| 114 |
+
const input = messageText.trim().toUpperCase()
|
| 115 |
+
if (input !== pairing.code.toUpperCase()) {
|
| 116 |
+
recordFailedAttempt(senderInfo.userId)
|
| 117 |
+
return false
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// 配对成功:写入 pairedUsers
|
| 121 |
+
const platformConfig = file[platform] ?? {}
|
| 122 |
+
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
| 123 |
+
|
| 124 |
+
// 避免重复
|
| 125 |
+
const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
|
| 126 |
+
if (!exists) {
|
| 127 |
+
pairedUsers.push({
|
| 128 |
+
userId: senderInfo.userId,
|
| 129 |
+
displayName: senderInfo.displayName,
|
| 130 |
+
pairedAt: Date.now(),
|
| 131 |
+
})
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// 更新 config
|
| 135 |
+
file[platform] = { ...platformConfig, pairedUsers }
|
| 136 |
+
file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
|
| 137 |
+
writeConfigFile(file)
|
| 138 |
+
|
| 139 |
+
return true
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/** 统一的用户授权检查(供各 adapter 调用) */
|
| 143 |
+
export function isAllowedUser(platform: ImPlatform, userId: string | number): boolean {
|
| 144 |
+
try {
|
| 145 |
+
const cfgFile = readConfigFile()
|
| 146 |
+
return isPaired(platform, userId, cfgFile)
|
| 147 |
+
} catch {
|
| 148 |
+
return false
|
| 149 |
+
}
|
| 150 |
+
}
|
adapters/common/permission.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type PermissionDecision = {
|
| 2 |
+
requestId: string
|
| 3 |
+
allowed: boolean
|
| 4 |
+
rule?: 'always'
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
function getSinglePendingRequestId(requestIds?: Iterable<string> | null): string | null {
|
| 8 |
+
if (!requestIds) return null
|
| 9 |
+
const ids = Array.from(requestIds)
|
| 10 |
+
return ids.length === 1 ? ids[0]! : null
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function parsePermissionCommand(
|
| 14 |
+
text: string,
|
| 15 |
+
pendingRequestIds?: Iterable<string> | null,
|
| 16 |
+
): PermissionDecision | null {
|
| 17 |
+
const trimmed = text.trim()
|
| 18 |
+
const match = text.trim().match(/^\/(allow|always|allow-always|deny)\s+(\S+)/i)
|
| 19 |
+
if (match) {
|
| 20 |
+
const action = match[1]!.toLowerCase()
|
| 21 |
+
const requestId = match[2]!
|
| 22 |
+
if (action === 'deny') return { requestId, allowed: false }
|
| 23 |
+
if (action === 'always' || action === 'allow-always') return { requestId, allowed: true, rule: 'always' }
|
| 24 |
+
return { requestId, allowed: true }
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const requestId = getSinglePendingRequestId(pendingRequestIds)
|
| 28 |
+
if (!requestId) return null
|
| 29 |
+
|
| 30 |
+
const shortcut = trimmed.toLowerCase()
|
| 31 |
+
if (['1', '/1', 'allow', '/allow', 'y', 'yes', '允许', '允许一次', '同意', '批准'].includes(shortcut)) {
|
| 32 |
+
return { requestId, allowed: true }
|
| 33 |
+
}
|
| 34 |
+
if (['2', '/2', 'always', '/always', 'allow-always', '/allow-always', '永久允许', '一直允许'].includes(shortcut)) {
|
| 35 |
+
return { requestId, allowed: true, rule: 'always' }
|
| 36 |
+
}
|
| 37 |
+
if (['3', '/3', 'deny', '/deny', 'n', 'no', '拒绝', '不允许', '否'].includes(shortcut)) {
|
| 38 |
+
return { requestId, allowed: false }
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return null
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function parsePermitCallbackData(data: string): PermissionDecision | null {
|
| 45 |
+
const parts = data.split(':')
|
| 46 |
+
if (parts.length !== 3 || parts[0] !== 'permit' || !parts[1]) return null
|
| 47 |
+
|
| 48 |
+
switch (parts[2]) {
|
| 49 |
+
case 'yes':
|
| 50 |
+
return { requestId: parts[1], allowed: true }
|
| 51 |
+
case 'always':
|
| 52 |
+
return { requestId: parts[1], allowed: true, rule: 'always' }
|
| 53 |
+
case 'no':
|
| 54 |
+
return { requestId: parts[1], allowed: false }
|
| 55 |
+
default:
|
| 56 |
+
return null
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function formatPermissionInstructions(requestId: string): string {
|
| 61 |
+
return [
|
| 62 |
+
'回复 1 允许一次,2 永久允许,3 拒绝。',
|
| 63 |
+
`也可回复 /allow ${requestId}、/always ${requestId}、/deny ${requestId}。`,
|
| 64 |
+
].join('\n')
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function formatPermissionDecisionStatus(decision: Pick<PermissionDecision, 'allowed' | 'rule'>): string {
|
| 68 |
+
if (!decision.allowed) return '❌ 已拒绝'
|
| 69 |
+
return decision.rule === 'always' ? '♾️ 已永久允许' : '✅ 已允许'
|
| 70 |
+
}
|
adapters/common/session-store.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as fs from 'node:fs'
|
| 2 |
+
import * as path from 'node:path'
|
| 3 |
+
import * as os from 'node:os'
|
| 4 |
+
|
| 5 |
+
export type SessionEntry = {
|
| 6 |
+
sessionId: string
|
| 7 |
+
workDir: string
|
| 8 |
+
updatedAt: number
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
type StoreData = Record<string, SessionEntry>
|
| 12 |
+
|
| 13 |
+
function getDefaultPath(): string {
|
| 14 |
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
| 15 |
+
return path.join(configDir, 'adapter-sessions.json')
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export class SessionStore {
|
| 19 |
+
private data: StoreData
|
| 20 |
+
private filePath: string
|
| 21 |
+
|
| 22 |
+
constructor(filePath?: string) {
|
| 23 |
+
this.filePath = filePath ?? getDefaultPath()
|
| 24 |
+
this.data = this.load()
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
get(chatId: string): SessionEntry | null {
|
| 28 |
+
this.refresh()
|
| 29 |
+
return this.data[chatId] ?? null
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
set(chatId: string, sessionId: string, workDir: string): void {
|
| 33 |
+
this.refresh()
|
| 34 |
+
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
|
| 35 |
+
this.save()
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
delete(chatId: string): void {
|
| 39 |
+
this.refresh()
|
| 40 |
+
delete this.data[chatId]
|
| 41 |
+
this.save()
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
deleteBySessionId(sessionId: string): string[] {
|
| 45 |
+
this.refresh()
|
| 46 |
+
const removed: string[] = []
|
| 47 |
+
for (const [chatId, entry] of Object.entries(this.data)) {
|
| 48 |
+
if (entry.sessionId !== sessionId) continue
|
| 49 |
+
delete this.data[chatId]
|
| 50 |
+
removed.push(chatId)
|
| 51 |
+
}
|
| 52 |
+
if (removed.length > 0) {
|
| 53 |
+
this.save()
|
| 54 |
+
}
|
| 55 |
+
return removed
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
listAll(): Array<{ chatId: string } & SessionEntry> {
|
| 59 |
+
this.refresh()
|
| 60 |
+
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
private refresh(): void {
|
| 64 |
+
this.data = this.load()
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
private load(): StoreData {
|
| 68 |
+
try {
|
| 69 |
+
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
|
| 70 |
+
} catch {
|
| 71 |
+
return {}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
private save(): void {
|
| 76 |
+
const dir = path.dirname(this.filePath)
|
| 77 |
+
fs.mkdirSync(dir, { recursive: true })
|
| 78 |
+
const tmp = `${this.filePath}.tmp.${Date.now()}`
|
| 79 |
+
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
|
| 80 |
+
fs.renameSync(tmp, this.filePath)
|
| 81 |
+
}
|
| 82 |
+
}
|
adapters/common/ws-bridge.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WebSocket Bridge
|
| 3 |
+
*
|
| 4 |
+
* 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。
|
| 5 |
+
* 管理 chatId → sessionId 映射,自动重连,心跳。
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import WebSocket from 'ws'
|
| 9 |
+
|
| 10 |
+
/** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef.
|
| 11 |
+
* The server will either (a) write base64 `data` to
|
| 12 |
+
* ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or
|
| 13 |
+
* (b) read `path` from disk and inject `@"path"` into the prompt. */
|
| 14 |
+
export type AttachmentRef = {
|
| 15 |
+
type: 'file' | 'image'
|
| 16 |
+
name?: string
|
| 17 |
+
path?: string
|
| 18 |
+
data?: string // base64 payload (images)
|
| 19 |
+
mimeType?: string
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */
|
| 23 |
+
export type ServerMessage = {
|
| 24 |
+
type: string
|
| 25 |
+
[key: string]: any
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** Callback for server messages */
|
| 29 |
+
export type MessageHandler = (msg: ServerMessage) => void
|
| 30 |
+
|
| 31 |
+
type Session = {
|
| 32 |
+
sessionId: string
|
| 33 |
+
ws: WebSocket
|
| 34 |
+
reconnectAttempts: number
|
| 35 |
+
reconnectTimer: ReturnType<typeof setTimeout> | null
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
| 39 |
+
const RECONNECT_BASE_MS = 1000
|
| 40 |
+
const RECONNECT_MAX_MS = 30_000
|
| 41 |
+
const MAX_RECONNECT_ATTEMPTS = 10
|
| 42 |
+
|
| 43 |
+
export class WsBridge {
|
| 44 |
+
private sessions = new Map<string, Session>()
|
| 45 |
+
/** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
|
| 46 |
+
private handlers = new Map<string, MessageHandler>()
|
| 47 |
+
/** Per-chat FIFO queue of in-flight handler promises.
|
| 48 |
+
* Ensures an async handler for message N completes before handler for N+1
|
| 49 |
+
* starts, preventing state races at `await` points. */
|
| 50 |
+
private handlerChains = new Map<string, Promise<void>>()
|
| 51 |
+
private serverUrl: string
|
| 52 |
+
private platform: string
|
| 53 |
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
| 54 |
+
private destroyed = false
|
| 55 |
+
|
| 56 |
+
constructor(serverUrl: string, platform: string) {
|
| 57 |
+
this.serverUrl = serverUrl.replace(/\/$/, '')
|
| 58 |
+
this.platform = platform
|
| 59 |
+
this.startHeartbeat()
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** Connect to a session with a known sessionId. Returns false if already connected. */
|
| 63 |
+
connectSession(chatId: string, sessionId: string): boolean {
|
| 64 |
+
const existing = this.sessions.get(chatId)
|
| 65 |
+
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
| 66 |
+
return false
|
| 67 |
+
}
|
| 68 |
+
this.connect(chatId, sessionId)
|
| 69 |
+
return true
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Send a user message to the session bound to chatId. */
|
| 73 |
+
sendUserMessage(
|
| 74 |
+
chatId: string,
|
| 75 |
+
content: string,
|
| 76 |
+
attachments?: AttachmentRef[],
|
| 77 |
+
): boolean {
|
| 78 |
+
const payload: Record<string, unknown> = { type: 'user_message', content }
|
| 79 |
+
if (attachments && attachments.length > 0) {
|
| 80 |
+
payload.attachments = attachments
|
| 81 |
+
}
|
| 82 |
+
return this.send(chatId, payload)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/** Respond to a permission request.
|
| 86 |
+
*
|
| 87 |
+
* @param rule - optional rule name to make the permission persistent.
|
| 88 |
+
* Currently the server supports `'always'`, which uses the CLI's
|
| 89 |
+
* permission_suggestions to produce updatedPermissions so the same
|
| 90 |
+
* tool call won't prompt again in this session. Omit for one-shot allow. */
|
| 91 |
+
sendPermissionResponse(
|
| 92 |
+
chatId: string,
|
| 93 |
+
requestId: string,
|
| 94 |
+
allowed: boolean,
|
| 95 |
+
rule?: string,
|
| 96 |
+
): boolean {
|
| 97 |
+
const message: Record<string, unknown> = {
|
| 98 |
+
type: 'permission_response',
|
| 99 |
+
requestId,
|
| 100 |
+
allowed,
|
| 101 |
+
}
|
| 102 |
+
if (rule) message.rule = rule
|
| 103 |
+
return this.send(chatId, message)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** Stop the current generation. */
|
| 107 |
+
sendStopGeneration(chatId: string): boolean {
|
| 108 |
+
return this.send(chatId, { type: 'stop_generation' })
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/** Register (or replace) the handler for server messages on a specific chatId. */
|
| 112 |
+
onServerMessage(chatId: string, handler: MessageHandler): void {
|
| 113 |
+
this.handlers.set(chatId, handler)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/** Reset session for a chatId (e.g. /new command). */
|
| 117 |
+
resetSession(chatId: string): void {
|
| 118 |
+
const session = this.sessions.get(chatId)
|
| 119 |
+
if (session) {
|
| 120 |
+
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
| 121 |
+
session.ws.close(1000, 'session reset')
|
| 122 |
+
this.sessions.delete(chatId)
|
| 123 |
+
}
|
| 124 |
+
this.handlers.delete(chatId)
|
| 125 |
+
this.handlerChains.delete(chatId)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/** Has a session (connected or handler registered) for chatId. */
|
| 129 |
+
hasSession(chatId: string): boolean {
|
| 130 |
+
return this.sessions.has(chatId) || this.handlers.has(chatId)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/** Destroy all sessions. */
|
| 134 |
+
destroy(): void {
|
| 135 |
+
this.destroyed = true
|
| 136 |
+
if (this.heartbeatTimer) {
|
| 137 |
+
clearInterval(this.heartbeatTimer)
|
| 138 |
+
this.heartbeatTimer = null
|
| 139 |
+
}
|
| 140 |
+
for (const [, session] of this.sessions) {
|
| 141 |
+
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
| 142 |
+
session.ws.close(1000, 'bridge destroyed')
|
| 143 |
+
}
|
| 144 |
+
this.sessions.clear()
|
| 145 |
+
this.handlers.clear()
|
| 146 |
+
this.handlerChains.clear()
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ------- internal -------
|
| 150 |
+
|
| 151 |
+
private connect(chatId: string, sessionId: string): void {
|
| 152 |
+
const url = `${this.serverUrl}/ws/${sessionId}`
|
| 153 |
+
const ws = new WebSocket(url)
|
| 154 |
+
|
| 155 |
+
// Cancel any pending reconnect timer for this chatId
|
| 156 |
+
const prev = this.sessions.get(chatId)
|
| 157 |
+
if (prev) {
|
| 158 |
+
if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
|
| 159 |
+
prev.ws.removeAllListeners()
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const session: Session = {
|
| 163 |
+
sessionId,
|
| 164 |
+
ws,
|
| 165 |
+
reconnectAttempts: prev?.reconnectAttempts ?? 0,
|
| 166 |
+
reconnectTimer: null,
|
| 167 |
+
}
|
| 168 |
+
this.sessions.set(chatId, session)
|
| 169 |
+
|
| 170 |
+
ws.on('open', () => {
|
| 171 |
+
console.log(`[WsBridge] Connected: ${sessionId}`)
|
| 172 |
+
session.reconnectAttempts = 0
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
ws.on('message', (raw) => {
|
| 176 |
+
let msg: ServerMessage
|
| 177 |
+
try {
|
| 178 |
+
msg = JSON.parse(raw.toString())
|
| 179 |
+
} catch (err) {
|
| 180 |
+
console.error('[WsBridge] Parse error:', err)
|
| 181 |
+
return
|
| 182 |
+
}
|
| 183 |
+
if (msg.type === 'pong') return
|
| 184 |
+
const handler = this.handlers.get(chatId)
|
| 185 |
+
if (!handler) return
|
| 186 |
+
|
| 187 |
+
// Serialize per-chat handler calls: chain each message onto the previous
|
| 188 |
+
// one so a slow handler (e.g. one awaiting im.message.create) fully
|
| 189 |
+
// finishes before the next message's handler runs. This prevents state
|
| 190 |
+
// races where a later message reads stale map entries set up by an
|
| 191 |
+
// earlier-but-still-in-flight handler.
|
| 192 |
+
const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
|
| 193 |
+
const next = prev
|
| 194 |
+
.catch(() => {}) // upstream errors must not poison the chain
|
| 195 |
+
.then(() => Promise.resolve().then(() => handler(msg)))
|
| 196 |
+
.catch((err) => {
|
| 197 |
+
console.error(`[WsBridge] Handler error on ${chatId}:`, err)
|
| 198 |
+
})
|
| 199 |
+
this.handlerChains.set(chatId, next)
|
| 200 |
+
})
|
| 201 |
+
|
| 202 |
+
ws.on('close', (code, reason) => {
|
| 203 |
+
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
|
| 204 |
+
if (this.sessions.get(chatId) !== session) return
|
| 205 |
+
if (code === 1000) {
|
| 206 |
+
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
| 207 |
+
this.sessions.delete(chatId)
|
| 208 |
+
this.handlers.delete(chatId)
|
| 209 |
+
this.handlerChains.delete(chatId)
|
| 210 |
+
return
|
| 211 |
+
}
|
| 212 |
+
this.scheduleReconnect(chatId, sessionId)
|
| 213 |
+
})
|
| 214 |
+
|
| 215 |
+
ws.on('error', (err) => {
|
| 216 |
+
console.error(`[WsBridge] Error on ${sessionId}:`, err.message)
|
| 217 |
+
})
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
|
| 221 |
+
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
|
| 222 |
+
const session = this.sessions.get(chatId)
|
| 223 |
+
if (!session) return Promise.resolve(false)
|
| 224 |
+
if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
|
| 225 |
+
return new Promise((resolve) => {
|
| 226 |
+
const timer = setTimeout(() => {
|
| 227 |
+
cleanup()
|
| 228 |
+
resolve(false)
|
| 229 |
+
}, timeoutMs)
|
| 230 |
+
const onOpen = () => { cleanup(); resolve(true) }
|
| 231 |
+
const onError = () => { cleanup(); resolve(false) }
|
| 232 |
+
const onClose = () => { cleanup(); resolve(false) }
|
| 233 |
+
const cleanup = () => {
|
| 234 |
+
clearTimeout(timer)
|
| 235 |
+
session.ws.removeListener('open', onOpen)
|
| 236 |
+
session.ws.removeListener('error', onError)
|
| 237 |
+
session.ws.removeListener('close', onClose)
|
| 238 |
+
}
|
| 239 |
+
session.ws.once('open', onOpen)
|
| 240 |
+
session.ws.once('error', onError)
|
| 241 |
+
session.ws.once('close', onClose)
|
| 242 |
+
})
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
private send(chatId: string, message: Record<string, unknown>): boolean {
|
| 246 |
+
const session = this.sessions.get(chatId)
|
| 247 |
+
if (!session || session.ws.readyState !== WebSocket.OPEN) {
|
| 248 |
+
console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
|
| 249 |
+
return false
|
| 250 |
+
}
|
| 251 |
+
session.ws.send(JSON.stringify(message))
|
| 252 |
+
return true
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
private scheduleReconnect(chatId: string, sessionId: string): void {
|
| 256 |
+
if (this.destroyed) return
|
| 257 |
+
const session = this.sessions.get(chatId)
|
| 258 |
+
if (!session) return
|
| 259 |
+
if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
| 260 |
+
console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`)
|
| 261 |
+
this.sessions.delete(chatId)
|
| 262 |
+
this.handlers.delete(chatId)
|
| 263 |
+
return
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
session.reconnectAttempts++
|
| 267 |
+
const delay = Math.min(
|
| 268 |
+
RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1),
|
| 269 |
+
RECONNECT_MAX_MS,
|
| 270 |
+
)
|
| 271 |
+
console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
|
| 272 |
+
session.reconnectTimer = setTimeout(() => {
|
| 273 |
+
if (this.destroyed) return
|
| 274 |
+
if (this.sessions.get(chatId)?.sessionId === sessionId) {
|
| 275 |
+
this.connect(chatId, sessionId)
|
| 276 |
+
}
|
| 277 |
+
}, delay)
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
private startHeartbeat(): void {
|
| 281 |
+
this.heartbeatTimer = setInterval(() => {
|
| 282 |
+
for (const [, session] of this.sessions) {
|
| 283 |
+
if (session.ws.readyState === WebSocket.OPEN) {
|
| 284 |
+
session.ws.send(JSON.stringify({ type: 'ping' }))
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}, HEARTBEAT_INTERVAL_MS)
|
| 288 |
+
}
|
| 289 |
+
}
|
adapters/dingtalk/__tests__/ai-card.test.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
| 2 |
+
import { buildDeliverBody, DingTalkAiCardService } from '../ai-card.js'
|
| 3 |
+
|
| 4 |
+
describe('DingTalk AI Card streaming', () => {
|
| 5 |
+
const originalFetch = globalThis.fetch
|
| 6 |
+
const calls: Array<{ url: string; method: string; body: any }> = []
|
| 7 |
+
|
| 8 |
+
beforeEach(() => {
|
| 9 |
+
calls.length = 0
|
| 10 |
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
| 11 |
+
calls.push({
|
| 12 |
+
url: String(url),
|
| 13 |
+
method: init?.method ?? 'GET',
|
| 14 |
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
| 15 |
+
})
|
| 16 |
+
return new Response('{}', { status: 200 })
|
| 17 |
+
}) as any
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
afterEach(() => {
|
| 21 |
+
globalThis.fetch = originalFetch
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('builds the official IM_ROBOT deliver payload', () => {
|
| 25 |
+
expect(buildDeliverBody('card-1', { type: 'user', userId: 'staff-1' }, 'robot-1')).toMatchObject({
|
| 26 |
+
outTrackId: 'card-1',
|
| 27 |
+
openSpaceId: 'dtv1.card//IM_ROBOT.staff-1',
|
| 28 |
+
imRobotOpenDeliverModel: {
|
| 29 |
+
spaceType: 'IM_ROBOT',
|
| 30 |
+
robotCode: 'robot-1',
|
| 31 |
+
},
|
| 32 |
+
})
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('creates, streams, and finishes an AI card', async () => {
|
| 36 |
+
const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1')
|
| 37 |
+
const card = await service.createForTarget({ type: 'user', userId: 'staff-1' })
|
| 38 |
+
|
| 39 |
+
expect(card?.cardInstanceId.startsWith('card_')).toBe(true)
|
| 40 |
+
expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
|
| 41 |
+
'POST /v1.0/card/instances',
|
| 42 |
+
'POST /v1.0/card/instances/deliver',
|
| 43 |
+
])
|
| 44 |
+
|
| 45 |
+
await service.stream(card!, 'Hello', false)
|
| 46 |
+
expect(calls.at(-2)?.body.cardData.cardParamMap.flowStatus).toBe('2')
|
| 47 |
+
expect(new URL(calls.at(-1)!.url).pathname).toBe('/v1.0/card/streaming')
|
| 48 |
+
expect(calls.at(-1)?.body).toMatchObject({
|
| 49 |
+
key: 'msgContent',
|
| 50 |
+
content: 'Hello',
|
| 51 |
+
isFull: true,
|
| 52 |
+
isFinalize: false,
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
calls.length = 0
|
| 56 |
+
await service.finish(card!, 'Final')
|
| 57 |
+
expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([
|
| 58 |
+
'PUT /v1.0/card/streaming',
|
| 59 |
+
'PUT /v1.0/card/instances',
|
| 60 |
+
])
|
| 61 |
+
expect(calls[0]!.body.isFinalize).toBe(true)
|
| 62 |
+
expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3')
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
it('times out a hung card streaming request', async () => {
|
| 66 |
+
const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 67 |
+
process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20'
|
| 68 |
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
| 69 |
+
calls.push({
|
| 70 |
+
url: String(url),
|
| 71 |
+
method: init?.method ?? 'GET',
|
| 72 |
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
| 73 |
+
})
|
| 74 |
+
return await new Promise<Response>((_, reject) => {
|
| 75 |
+
init?.signal?.addEventListener('abort', () => {
|
| 76 |
+
reject(new DOMException('aborted', 'AbortError'))
|
| 77 |
+
})
|
| 78 |
+
})
|
| 79 |
+
}) as any
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1')
|
| 83 |
+
const card = {
|
| 84 |
+
cardInstanceId: 'card-hung',
|
| 85 |
+
accessToken: 'token-1',
|
| 86 |
+
tokenExpireTime: Date.now() + 60_000,
|
| 87 |
+
inputingStarted: true,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
await expect(service.stream(card, 'Hello', false)).rejects.toThrow(
|
| 91 |
+
'PUT /v1.0/card/streaming timed out after 20ms',
|
| 92 |
+
)
|
| 93 |
+
} finally {
|
| 94 |
+
if (previousTimeout === undefined) {
|
| 95 |
+
delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 96 |
+
} else {
|
| 97 |
+
process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
})
|
| 101 |
+
})
|
adapters/dingtalk/__tests__/helpers.test.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'bun:test'
|
| 2 |
+
import {
|
| 3 |
+
extractDingTalkAttachments,
|
| 4 |
+
extractDingTalkText,
|
| 5 |
+
getDingTalkChatId,
|
| 6 |
+
getDingTalkSenderId,
|
| 7 |
+
isDingTalkDirectMessage,
|
| 8 |
+
parseDingTalkPayload,
|
| 9 |
+
} from '../helpers.js'
|
| 10 |
+
|
| 11 |
+
describe('DingTalk helpers', () => {
|
| 12 |
+
it('parses robot payload JSON safely', () => {
|
| 13 |
+
expect(parseDingTalkPayload('{"msgtype":"text"}')?.msgtype).toBe('text')
|
| 14 |
+
expect(parseDingTalkPayload('not-json')).toBeNull()
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
it('extracts sender and chat ids for direct messages', () => {
|
| 18 |
+
const data = {
|
| 19 |
+
conversationType: '1',
|
| 20 |
+
senderStaffId: 'staff-1',
|
| 21 |
+
conversationId: 'cid-1',
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
expect(isDingTalkDirectMessage(data)).toBe(true)
|
| 25 |
+
expect(getDingTalkSenderId(data)).toBe('staff-1')
|
| 26 |
+
expect(getDingTalkChatId(data)).toBe('dingtalk:dm:staff-1')
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
it('extracts text from common DingTalk content shapes', () => {
|
| 30 |
+
expect(extractDingTalkText({ text: { content: ' hello ' } })).toBe('hello')
|
| 31 |
+
expect(extractDingTalkText({ content: '{"text":"from content"}' })).toBe('from content')
|
| 32 |
+
expect(extractDingTalkText({
|
| 33 |
+
content: {
|
| 34 |
+
richText: [
|
| 35 |
+
{ text: 'hello' },
|
| 36 |
+
{ text: ' world' },
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
})).toBe('hello world')
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
it('extracts image and file attachment candidates', () => {
|
| 43 |
+
expect(extractDingTalkAttachments({
|
| 44 |
+
msgtype: 'picture',
|
| 45 |
+
content: { pictureUrl: 'https://example.com/a.jpg', downloadCode: 'pic-code' },
|
| 46 |
+
})).toEqual([{ kind: 'image', url: 'https://example.com/a.jpg', downloadCode: 'pic-code' }])
|
| 47 |
+
|
| 48 |
+
expect(extractDingTalkAttachments({
|
| 49 |
+
msgtype: 'file',
|
| 50 |
+
content: '{"fileName":"report.pdf","downloadCode":"file-code"}',
|
| 51 |
+
})).toEqual([{ kind: 'file', downloadCode: 'file-code', fileName: 'report.pdf' }])
|
| 52 |
+
|
| 53 |
+
expect(extractDingTalkAttachments({
|
| 54 |
+
msgtype: 'richText',
|
| 55 |
+
content: { richText: [{ text: 'hi' }, { type: 'picture', downloadCode: 'rich-pic' }] },
|
| 56 |
+
})).toEqual([{ kind: 'image', url: undefined, downloadCode: 'rich-pic' }])
|
| 57 |
+
})
|
| 58 |
+
})
|
adapters/dingtalk/__tests__/permission-card.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'bun:test'
|
| 2 |
+
import {
|
| 3 |
+
buildDingTalkPermissionCardParams,
|
| 4 |
+
parseDingTalkPermissionCardAction,
|
| 5 |
+
} from '../permission-card.js'
|
| 6 |
+
|
| 7 |
+
describe('DingTalk permission card helpers', () => {
|
| 8 |
+
it('builds template params with three permission actions', () => {
|
| 9 |
+
const params = buildDingTalkPermissionCardParams('Bash', { command: 'npm test' }, 'req-1')
|
| 10 |
+
|
| 11 |
+
expect(params.requestId).toBe('req-1')
|
| 12 |
+
expect(params.toolName).toBe('Bash')
|
| 13 |
+
expect(String(params.inputPreview)).toContain('npm test')
|
| 14 |
+
expect(JSON.parse(String(params.allowValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true })
|
| 15 |
+
expect(JSON.parse(String(params.alwaysValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true, rule: 'always' })
|
| 16 |
+
expect(JSON.parse(String(params.denyValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: false })
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
it('parses nested card private params', () => {
|
| 20 |
+
const action = parseDingTalkPermissionCardAction({
|
| 21 |
+
outTrackId: 'permission_req-1',
|
| 22 |
+
content: JSON.stringify({
|
| 23 |
+
cardPrivateData: {
|
| 24 |
+
params: {
|
| 25 |
+
action: 'permit',
|
| 26 |
+
requestId: 'req-1',
|
| 27 |
+
allowed: true,
|
| 28 |
+
rule: 'always',
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
}),
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
expect(action).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' })
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
it('parses compact callback values', () => {
|
| 38 |
+
expect(parseDingTalkPermissionCardAction({ actionValue: 'permit:req-2:no' })).toEqual({
|
| 39 |
+
requestId: 'req-2',
|
| 40 |
+
allowed: false,
|
| 41 |
+
})
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
it('ignores callbacks without permission action data', () => {
|
| 45 |
+
expect(parseDingTalkPermissionCardAction({ action: 'open_url', url: 'https://example.com' })).toBeNull()
|
| 46 |
+
})
|
| 47 |
+
})
|
adapters/dingtalk/__tests__/stream-state.test.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'bun:test'
|
| 2 |
+
import { MessageBuffer } from '../../common/message-buffer.js'
|
| 3 |
+
import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from '../stream-state.js'
|
| 4 |
+
|
| 5 |
+
describe('DingTalk streaming state', () => {
|
| 6 |
+
it('drops the active AI card stream when permission interrupts output ordering', async () => {
|
| 7 |
+
let resetCalled = false
|
| 8 |
+
const buffer = new MessageBuffer(async () => {}, 100, 1000)
|
| 9 |
+
const originalReset = buffer.reset.bind(buffer)
|
| 10 |
+
buffer.reset = () => {
|
| 11 |
+
resetCalled = true
|
| 12 |
+
originalReset()
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const state = {
|
| 16 |
+
aiCardBuffers: new Map([['chat-1', buffer]]),
|
| 17 |
+
streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
|
| 18 |
+
streamingCardText: new Map([['chat-1', 'pre-permission text']]),
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
resetDingTalkStreamingState(state, 'chat-1')
|
| 22 |
+
|
| 23 |
+
expect(resetCalled).toBe(true)
|
| 24 |
+
expect(state.aiCardBuffers.has('chat-1')).toBe(false)
|
| 25 |
+
expect(state.streamingCards.has('chat-1')).toBe(false)
|
| 26 |
+
expect(state.streamingCardText.has('chat-1')).toBe(false)
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
it('completes the existing stream before dropping it for a permission request', async () => {
|
| 30 |
+
const flushed: Array<{ text: string; complete: boolean }> = []
|
| 31 |
+
let finalized = false
|
| 32 |
+
const buffer = new MessageBuffer(
|
| 33 |
+
async (text, complete) => {
|
| 34 |
+
flushed.push({ text, complete })
|
| 35 |
+
},
|
| 36 |
+
100,
|
| 37 |
+
1000,
|
| 38 |
+
)
|
| 39 |
+
buffer.append('pre-permission text')
|
| 40 |
+
|
| 41 |
+
const state = {
|
| 42 |
+
aiCardBuffers: new Map([['chat-1', buffer]]),
|
| 43 |
+
streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
|
| 44 |
+
streamingCardText: new Map([['chat-1', 'already streamed']]),
|
| 45 |
+
finalize: async () => {
|
| 46 |
+
finalized = true
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
await finishAndResetDingTalkStreamingState(state, 'chat-1')
|
| 51 |
+
|
| 52 |
+
expect(flushed).toEqual([{ text: 'pre-permission text', complete: true }])
|
| 53 |
+
expect(finalized).toBe(true)
|
| 54 |
+
expect(state.aiCardBuffers.has('chat-1')).toBe(false)
|
| 55 |
+
expect(state.streamingCards.has('chat-1')).toBe(false)
|
| 56 |
+
expect(state.streamingCardText.has('chat-1')).toBe(false)
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
it('finalizes an already-flushed card even when the message buffer is empty', async () => {
|
| 60 |
+
let finalized = false
|
| 61 |
+
const buffer = new MessageBuffer(async () => {}, 100, 1000)
|
| 62 |
+
const state = {
|
| 63 |
+
aiCardBuffers: new Map([['chat-1', buffer]]),
|
| 64 |
+
streamingCards: new Map([['chat-1', Promise.resolve(null)]]),
|
| 65 |
+
streamingCardText: new Map([['chat-1', 'already streamed']]),
|
| 66 |
+
finalize: async () => {
|
| 67 |
+
finalized = true
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
await finishAndResetDingTalkStreamingState(state, 'chat-1')
|
| 72 |
+
|
| 73 |
+
expect(finalized).toBe(true)
|
| 74 |
+
expect(state.aiCardBuffers.has('chat-1')).toBe(false)
|
| 75 |
+
expect(state.streamingCards.has('chat-1')).toBe(false)
|
| 76 |
+
expect(state.streamingCardText.has('chat-1')).toBe(false)
|
| 77 |
+
})
|
| 78 |
+
})
|
adapters/dingtalk/ai-card.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const DINGTALK_API = 'https://api.dingtalk.com'
|
| 2 |
+
const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'
|
| 3 |
+
const CARD_API_MAX_QPS = 20
|
| 4 |
+
const QPS_BACKOFF_DURATION_MS = 2_000
|
| 5 |
+
const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
|
| 6 |
+
|
| 7 |
+
const AICardStatus = {
|
| 8 |
+
INPUTING: '2',
|
| 9 |
+
FINISHED: '3',
|
| 10 |
+
} as const
|
| 11 |
+
type AICardFlowStatus = (typeof AICardStatus)[keyof typeof AICardStatus]
|
| 12 |
+
|
| 13 |
+
export type DingTalkAiCardTarget =
|
| 14 |
+
| { type: 'user'; userId: string }
|
| 15 |
+
| { type: 'group'; openConversationId: string }
|
| 16 |
+
|
| 17 |
+
export type DingTalkAiCardInstance = {
|
| 18 |
+
cardInstanceId: string
|
| 19 |
+
accessToken: string
|
| 20 |
+
tokenExpireTime: number
|
| 21 |
+
inputingStarted: boolean
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export type DingTalkCreateCardOptions = {
|
| 25 |
+
cardTemplateId?: string
|
| 26 |
+
outTrackId?: string
|
| 27 |
+
cardParamMap?: Record<string, unknown>
|
| 28 |
+
callbackRouteKey?: string
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type TokenProvider = () => Promise<string>
|
| 32 |
+
|
| 33 |
+
export class DingTalkAiCardService {
|
| 34 |
+
constructor(
|
| 35 |
+
private readonly getAccessToken: TokenProvider,
|
| 36 |
+
private readonly robotCode: string,
|
| 37 |
+
) {}
|
| 38 |
+
|
| 39 |
+
async createForTarget(
|
| 40 |
+
target: DingTalkAiCardTarget,
|
| 41 |
+
options: DingTalkCreateCardOptions = {},
|
| 42 |
+
): Promise<DingTalkAiCardInstance | null> {
|
| 43 |
+
try {
|
| 44 |
+
const token = await this.getAccessToken()
|
| 45 |
+
const cardInstanceId = options.outTrackId ?? `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
| 46 |
+
const createBody: Record<string, unknown> = {
|
| 47 |
+
cardTemplateId: options.cardTemplateId || AI_CARD_TEMPLATE_ID,
|
| 48 |
+
outTrackId: cardInstanceId,
|
| 49 |
+
cardData: {
|
| 50 |
+
cardParamMap: {
|
| 51 |
+
config: JSON.stringify({ autoLayout: true }),
|
| 52 |
+
...options.cardParamMap,
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
callbackType: 'STREAM',
|
| 56 |
+
imGroupOpenSpaceModel: { supportForward: true },
|
| 57 |
+
imRobotOpenSpaceModel: { supportForward: true },
|
| 58 |
+
}
|
| 59 |
+
if (options.callbackRouteKey) createBody.callbackRouteKey = options.callbackRouteKey
|
| 60 |
+
await postJson('/v1.0/card/instances', token, createBody)
|
| 61 |
+
|
| 62 |
+
await postJson('/v1.0/card/instances/deliver', token, buildDeliverBody(cardInstanceId, target, this.robotCode))
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
cardInstanceId,
|
| 66 |
+
accessToken: token,
|
| 67 |
+
tokenExpireTime: Date.now() + 2 * 60 * 60 * 1000,
|
| 68 |
+
inputingStarted: false,
|
| 69 |
+
}
|
| 70 |
+
} catch (err) {
|
| 71 |
+
console.warn('[DingTalk][AICard] create failed:', err instanceof Error ? err.message : err)
|
| 72 |
+
return null
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
async stream(card: DingTalkAiCardInstance, content: string, finished = false): Promise<void> {
|
| 77 |
+
await this.ensureValidToken(card)
|
| 78 |
+
|
| 79 |
+
if (!card.inputingStarted) {
|
| 80 |
+
await this.updateStatus(card, AICardStatus.INPUTING, content)
|
| 81 |
+
card.inputingStarted = true
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
await withCardRateLimit(() =>
|
| 85 |
+
putJson('/v1.0/card/streaming', card.accessToken, {
|
| 86 |
+
outTrackId: card.cardInstanceId,
|
| 87 |
+
guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
| 88 |
+
key: 'msgContent',
|
| 89 |
+
content: ensureTableBlankLines(content),
|
| 90 |
+
isFull: true,
|
| 91 |
+
isFinalize: finished,
|
| 92 |
+
isError: false,
|
| 93 |
+
}),
|
| 94 |
+
)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async finish(card: DingTalkAiCardInstance, content: string): Promise<void> {
|
| 98 |
+
await this.stream(card, content, true)
|
| 99 |
+
try {
|
| 100 |
+
await this.updateStatus(card, AICardStatus.FINISHED, ensureTableBlankLines(content))
|
| 101 |
+
} catch (err) {
|
| 102 |
+
console.warn('[DingTalk][AICard] finish status failed:', err instanceof Error ? err.message : err)
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
private async updateStatus(
|
| 107 |
+
card: DingTalkAiCardInstance,
|
| 108 |
+
flowStatus: AICardFlowStatus,
|
| 109 |
+
content: string,
|
| 110 |
+
): Promise<void> {
|
| 111 |
+
const body: Record<string, unknown> = {
|
| 112 |
+
outTrackId: card.cardInstanceId,
|
| 113 |
+
cardData: {
|
| 114 |
+
cardParamMap: {
|
| 115 |
+
flowStatus,
|
| 116 |
+
msgContent: ensureTableBlankLines(content),
|
| 117 |
+
staticMsgContent: '',
|
| 118 |
+
sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
|
| 119 |
+
config: JSON.stringify({ autoLayout: true }),
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
}
|
| 123 |
+
if (flowStatus === AICardStatus.FINISHED) {
|
| 124 |
+
body.cardUpdateOptions = { updateCardDataByKey: true }
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
await withCardRateLimit(() =>
|
| 128 |
+
putJson('/v1.0/card/instances', card.accessToken, body),
|
| 129 |
+
)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
private async ensureValidToken(card: DingTalkAiCardInstance): Promise<void> {
|
| 133 |
+
if (Date.now() <= card.tokenExpireTime - 5 * 60 * 1000) return
|
| 134 |
+
card.accessToken = await this.getAccessToken()
|
| 135 |
+
card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
export function buildDeliverBody(
|
| 140 |
+
cardInstanceId: string,
|
| 141 |
+
target: DingTalkAiCardTarget,
|
| 142 |
+
robotCode: string,
|
| 143 |
+
): Record<string, unknown> {
|
| 144 |
+
const base = { outTrackId: cardInstanceId, userIdType: 1 }
|
| 145 |
+
if (target.type === 'group') {
|
| 146 |
+
return {
|
| 147 |
+
...base,
|
| 148 |
+
openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
|
| 149 |
+
imGroupOpenDeliverModel: {
|
| 150 |
+
robotCode,
|
| 151 |
+
},
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
...base,
|
| 157 |
+
openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
|
| 158 |
+
imRobotOpenDeliverModel: {
|
| 159 |
+
spaceType: 'IM_ROBOT',
|
| 160 |
+
robotCode,
|
| 161 |
+
extension: {
|
| 162 |
+
dynamicSummary: 'true',
|
| 163 |
+
},
|
| 164 |
+
},
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
async function postJson(path: string, token: string, body: Record<string, unknown>): Promise<void> {
|
| 169 |
+
await requestJson('POST', path, token, body)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
async function putJson(path: string, token: string, body: Record<string, unknown>): Promise<void> {
|
| 173 |
+
await requestJson('PUT', path, token, body)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async function requestJson(
|
| 177 |
+
method: 'POST' | 'PUT',
|
| 178 |
+
path: string,
|
| 179 |
+
token: string,
|
| 180 |
+
body: Record<string, unknown>,
|
| 181 |
+
): Promise<void> {
|
| 182 |
+
const controller = new AbortController()
|
| 183 |
+
const timeoutMs = getImCardRequestTimeoutMs()
|
| 184 |
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
| 185 |
+
try {
|
| 186 |
+
const res = await fetch(`${DINGTALK_API}${path}`, {
|
| 187 |
+
method,
|
| 188 |
+
headers: {
|
| 189 |
+
'Content-Type': 'application/json',
|
| 190 |
+
'x-acs-dingtalk-access-token': token,
|
| 191 |
+
},
|
| 192 |
+
body: JSON.stringify(body),
|
| 193 |
+
signal: controller.signal,
|
| 194 |
+
})
|
| 195 |
+
if (!res.ok) {
|
| 196 |
+
const text = await res.text().catch(() => '')
|
| 197 |
+
const err = new Error(`${method} ${path} failed: ${res.status} ${text}`)
|
| 198 |
+
;(err as any).status = res.status
|
| 199 |
+
;(err as any).body = text
|
| 200 |
+
throw err
|
| 201 |
+
}
|
| 202 |
+
} catch (err) {
|
| 203 |
+
if ((err as Error)?.name === 'AbortError') {
|
| 204 |
+
throw new Error(`${method} ${path} timed out after ${timeoutMs}ms`)
|
| 205 |
+
}
|
| 206 |
+
throw err
|
| 207 |
+
} finally {
|
| 208 |
+
clearTimeout(timer)
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function getImCardRequestTimeoutMs(): number {
|
| 213 |
+
const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 214 |
+
const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
| 215 |
+
return Number.isFinite(parsed) && parsed > 0
|
| 216 |
+
? parsed
|
| 217 |
+
: DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function withCardRateLimit(fn: () => Promise<void>): Promise<void> {
|
| 221 |
+
await cardRateLimiter.waitForToken()
|
| 222 |
+
try {
|
| 223 |
+
await fn()
|
| 224 |
+
} catch (err) {
|
| 225 |
+
if (!isQpsLimitError(err)) throw err
|
| 226 |
+
cardRateLimiter.triggerBackoff()
|
| 227 |
+
await cardRateLimiter.waitForToken()
|
| 228 |
+
await fn()
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function isQpsLimitError(err: unknown): boolean {
|
| 233 |
+
return (err as any)?.status === 403 && String((err as any)?.body ?? '').includes('QpsLimit')
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const cardRateLimiter = {
|
| 237 |
+
tokens: CARD_API_MAX_QPS,
|
| 238 |
+
lastRefillTime: Date.now(),
|
| 239 |
+
backoffUntil: 0,
|
| 240 |
+
queueTail: Promise.resolve() as Promise<unknown>,
|
| 241 |
+
|
| 242 |
+
refill(): void {
|
| 243 |
+
const now = Date.now()
|
| 244 |
+
const elapsedSeconds = (now - this.lastRefillTime) / 1000
|
| 245 |
+
if (elapsedSeconds <= 0) return
|
| 246 |
+
this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS)
|
| 247 |
+
this.lastRefillTime = now
|
| 248 |
+
},
|
| 249 |
+
|
| 250 |
+
async waitForToken(): Promise<void> {
|
| 251 |
+
const prev = this.queueTail
|
| 252 |
+
let release!: () => void
|
| 253 |
+
this.queueTail = new Promise<void>((resolve) => {
|
| 254 |
+
release = resolve
|
| 255 |
+
})
|
| 256 |
+
try {
|
| 257 |
+
await prev.catch(() => {})
|
| 258 |
+
const now = Date.now()
|
| 259 |
+
if (now < this.backoffUntil) await sleep(this.backoffUntil - now)
|
| 260 |
+
this.refill()
|
| 261 |
+
if (this.tokens < 1) {
|
| 262 |
+
await sleep(Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000))
|
| 263 |
+
this.refill()
|
| 264 |
+
}
|
| 265 |
+
this.tokens -= 1
|
| 266 |
+
} finally {
|
| 267 |
+
release()
|
| 268 |
+
}
|
| 269 |
+
},
|
| 270 |
+
|
| 271 |
+
triggerBackoff(): void {
|
| 272 |
+
const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS
|
| 273 |
+
this.backoffUntil = backoffEnd
|
| 274 |
+
this.tokens = 0
|
| 275 |
+
this.lastRefillTime = backoffEnd
|
| 276 |
+
},
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function sleep(ms: number): Promise<void> {
|
| 280 |
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function ensureTableBlankLines(text: string): string {
|
| 284 |
+
const lines = text.split('\n')
|
| 285 |
+
const result: string[] = []
|
| 286 |
+
const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/
|
| 287 |
+
const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/
|
| 288 |
+
|
| 289 |
+
for (let i = 0; i < lines.length; i++) {
|
| 290 |
+
const currentLine = lines[i] ?? ''
|
| 291 |
+
const nextLine = lines[i + 1] ?? ''
|
| 292 |
+
if (
|
| 293 |
+
tableRowRegex.test(currentLine) &&
|
| 294 |
+
nextLine.includes('|') &&
|
| 295 |
+
tableDividerRegex.test(nextLine) &&
|
| 296 |
+
i > 0 &&
|
| 297 |
+
lines[i - 1]?.trim() !== '' &&
|
| 298 |
+
!tableRowRegex.test(lines[i - 1] ?? '')
|
| 299 |
+
) {
|
| 300 |
+
result.push('')
|
| 301 |
+
}
|
| 302 |
+
result.push(currentLine)
|
| 303 |
+
}
|
| 304 |
+
return result.join('\n')
|
| 305 |
+
}
|
adapters/dingtalk/helpers.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type DingTalkRobotMessage = {
|
| 2 |
+
msgId?: string
|
| 3 |
+
msgtype?: string
|
| 4 |
+
conversationType?: string
|
| 5 |
+
conversationId?: string
|
| 6 |
+
conversationTitle?: string
|
| 7 |
+
senderStaffId?: string
|
| 8 |
+
senderId?: string
|
| 9 |
+
senderNick?: string
|
| 10 |
+
sessionWebhook?: string
|
| 11 |
+
text?: { content?: string }
|
| 12 |
+
markdown?: { text?: string; title?: string }
|
| 13 |
+
content?: unknown
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export type DingTalkAttachmentCandidate = {
|
| 17 |
+
kind: 'image' | 'file'
|
| 18 |
+
url?: string
|
| 19 |
+
downloadCode?: string
|
| 20 |
+
fileName?: string
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function parseDingTalkPayload(raw: unknown): DingTalkRobotMessage | null {
|
| 24 |
+
if (!raw) return null
|
| 25 |
+
if (typeof raw === 'object') return raw as DingTalkRobotMessage
|
| 26 |
+
if (typeof raw !== 'string') return null
|
| 27 |
+
try {
|
| 28 |
+
const parsed = JSON.parse(raw)
|
| 29 |
+
return parsed && typeof parsed === 'object' ? parsed as DingTalkRobotMessage : null
|
| 30 |
+
} catch {
|
| 31 |
+
return null
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
export function isDingTalkDirectMessage(data: DingTalkRobotMessage): boolean {
|
| 35 |
+
return data.conversationType === '1'
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function getDingTalkSenderId(data: DingTalkRobotMessage): string | null {
|
| 39 |
+
const senderId = data.senderStaffId || data.senderId
|
| 40 |
+
return senderId ? String(senderId) : null
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function getDingTalkChatId(data: DingTalkRobotMessage): string | null {
|
| 44 |
+
const senderId = getDingTalkSenderId(data)
|
| 45 |
+
if (isDingTalkDirectMessage(data)) {
|
| 46 |
+
return senderId ? `dingtalk:dm:${senderId}` : null
|
| 47 |
+
}
|
| 48 |
+
return data.conversationId ? `dingtalk:group:${data.conversationId}` : null
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function extractDingTalkText(data: DingTalkRobotMessage): string {
|
| 52 |
+
if (typeof data.text?.content === 'string') return data.text.content.trim()
|
| 53 |
+
if (typeof data.markdown?.text === 'string') return data.markdown.text.trim()
|
| 54 |
+
|
| 55 |
+
const content = resolveContentObject(data.content)
|
| 56 |
+
if (typeof content?.text === 'string') return content.text.trim()
|
| 57 |
+
if (Array.isArray(content?.richText)) {
|
| 58 |
+
return content.richText
|
| 59 |
+
.map((item: unknown) => {
|
| 60 |
+
if (!item || typeof item !== 'object') return ''
|
| 61 |
+
const text = (item as { text?: unknown }).text
|
| 62 |
+
return typeof text === 'string' ? text : ''
|
| 63 |
+
})
|
| 64 |
+
.join('')
|
| 65 |
+
.trim()
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return ''
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function extractDingTalkAttachments(data: DingTalkRobotMessage): DingTalkAttachmentCandidate[] {
|
| 72 |
+
const content = resolveContentObject(data.content)
|
| 73 |
+
const candidates: DingTalkAttachmentCandidate[] = []
|
| 74 |
+
|
| 75 |
+
if (data.msgtype === 'picture') {
|
| 76 |
+
const url = stringValue(content?.pictureUrl)
|
| 77 |
+
const downloadCode = stringValue(content?.downloadCode)
|
| 78 |
+
if (url || downloadCode) candidates.push({ kind: 'image', url, downloadCode })
|
| 79 |
+
} else if (data.msgtype === 'file') {
|
| 80 |
+
const downloadCode = stringValue(content?.downloadCode)
|
| 81 |
+
const fileName = stringValue(content?.fileName) || 'dingtalk-file'
|
| 82 |
+
if (downloadCode) candidates.push({ kind: 'file', downloadCode, fileName })
|
| 83 |
+
} else if (data.msgtype === 'richText') {
|
| 84 |
+
const richText = Array.isArray(content?.richText) ? content.richText : []
|
| 85 |
+
for (const item of richText) {
|
| 86 |
+
if (!item || typeof item !== 'object') continue
|
| 87 |
+
const record = item as Record<string, unknown>
|
| 88 |
+
const pictureUrl = stringValue(record.pictureUrl)
|
| 89 |
+
const downloadCode = stringValue(record.downloadCode)
|
| 90 |
+
if (pictureUrl || downloadCode) {
|
| 91 |
+
candidates.push({ kind: 'image', url: pictureUrl, downloadCode })
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return candidates
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function stringValue(value: unknown): string | undefined {
|
| 100 |
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function resolveContentObject(raw: unknown): Record<string, any> | null {
|
| 104 |
+
if (!raw) return null
|
| 105 |
+
if (typeof raw === 'object') return raw as Record<string, any>
|
| 106 |
+
if (typeof raw !== 'string') return null
|
| 107 |
+
try {
|
| 108 |
+
const parsed = JSON.parse(raw)
|
| 109 |
+
return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null
|
| 110 |
+
} catch {
|
| 111 |
+
return null
|
| 112 |
+
}
|
| 113 |
+
}
|
adapters/dingtalk/index.ts
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DingTalk Adapter for Claude Code Desktop.
|
| 3 |
+
*
|
| 4 |
+
* Uses DingTalk Stream to receive bot messages without a public webhook.
|
| 5 |
+
* The desktop Settings page stores clientId/clientSecret via QR registration.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import path from 'node:path'
|
| 9 |
+
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from 'dingtalk-stream'
|
| 10 |
+
import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
|
| 11 |
+
import { MessageDedup } from '../common/message-dedup.js'
|
| 12 |
+
import { MessageBuffer } from '../common/message-buffer.js'
|
| 13 |
+
import { enqueue } from '../common/chat-queue.js'
|
| 14 |
+
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
|
| 15 |
+
import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js'
|
| 16 |
+
import {
|
| 17 |
+
formatPermissionDecisionStatus,
|
| 18 |
+
formatPermissionInstructions,
|
| 19 |
+
parsePermissionCommand,
|
| 20 |
+
type PermissionDecision,
|
| 21 |
+
} from '../common/permission.js'
|
| 22 |
+
import { SessionStore } from '../common/session-store.js'
|
| 23 |
+
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
|
| 24 |
+
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
| 25 |
+
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
| 26 |
+
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
| 27 |
+
import {
|
| 28 |
+
extractDingTalkAttachments,
|
| 29 |
+
extractDingTalkText,
|
| 30 |
+
getDingTalkChatId,
|
| 31 |
+
getDingTalkSenderId,
|
| 32 |
+
isDingTalkDirectMessage,
|
| 33 |
+
parseDingTalkPayload,
|
| 34 |
+
type DingTalkRobotMessage,
|
| 35 |
+
} from './helpers.js'
|
| 36 |
+
import { DingTalkMediaService } from './media.js'
|
| 37 |
+
import {
|
| 38 |
+
DingTalkAiCardService,
|
| 39 |
+
type DingTalkAiCardInstance,
|
| 40 |
+
type DingTalkAiCardTarget,
|
| 41 |
+
} from './ai-card.js'
|
| 42 |
+
import {
|
| 43 |
+
buildDingTalkPermissionCardParams,
|
| 44 |
+
DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
|
| 45 |
+
parseDingTalkPermissionCardAction,
|
| 46 |
+
} from './permission-card.js'
|
| 47 |
+
import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from './stream-state.js'
|
| 48 |
+
|
| 49 |
+
const DINGTALK_API = 'https://api.dingtalk.com'
|
| 50 |
+
|
| 51 |
+
const config = loadConfig()
|
| 52 |
+
if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
|
| 53 |
+
console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.')
|
| 54 |
+
process.exit(1)
|
| 55 |
+
}
|
| 56 |
+
const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk)
|
| 57 |
+
|
| 58 |
+
const bridge = new WsBridge(config.serverUrl, 'dingtalk')
|
| 59 |
+
const dedup = new MessageDedup()
|
| 60 |
+
const sessionStore = new SessionStore()
|
| 61 |
+
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
|
| 62 |
+
const attachmentStore = new AttachmentStore()
|
| 63 |
+
const media = new DingTalkMediaService(attachmentStore)
|
| 64 |
+
const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
|
| 65 |
+
const sessionWebhooks = new Map<string, string>()
|
| 66 |
+
const pendingProjectSelection = new Map<string, boolean>()
|
| 67 |
+
const runtimeStates = new Map<string, ChatRuntimeState>()
|
| 68 |
+
const aiCardBuffers = new Map<string, MessageBuffer>()
|
| 69 |
+
const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
|
| 70 |
+
const streamingCards = new Map<string, Promise<DingTalkAiCardInstance | null>>()
|
| 71 |
+
const streamingCardText = new Map<string, string>()
|
| 72 |
+
const pendingPermissions = new Map<string, Set<string>>()
|
| 73 |
+
const pendingPermissionChats = new Map<string, string>()
|
| 74 |
+
|
| 75 |
+
let accessTokenCache: { token: string; expiresAt: number } | null = null
|
| 76 |
+
|
| 77 |
+
attachmentStore.gc().catch((err) => {
|
| 78 |
+
console.warn('[DingTalk] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
type ChatRuntimeState = {
|
| 82 |
+
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
|
| 83 |
+
verb?: string
|
| 84 |
+
model?: string
|
| 85 |
+
pendingPermissionCount: number
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function getRuntimeState(chatId: string): ChatRuntimeState {
|
| 89 |
+
let state = runtimeStates.get(chatId)
|
| 90 |
+
if (!state) {
|
| 91 |
+
state = { state: 'idle', pendingPermissionCount: 0 }
|
| 92 |
+
runtimeStates.set(chatId, state)
|
| 93 |
+
}
|
| 94 |
+
return state
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function getAccessToken(): Promise<string> {
|
| 98 |
+
const now = Date.now()
|
| 99 |
+
if (accessTokenCache && accessTokenCache.expiresAt > now + 60_000) {
|
| 100 |
+
return accessTokenCache.token
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const res = await fetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
|
| 104 |
+
method: 'POST',
|
| 105 |
+
headers: { 'Content-Type': 'application/json' },
|
| 106 |
+
body: JSON.stringify({
|
| 107 |
+
appKey: config.dingtalk.clientId,
|
| 108 |
+
appSecret: config.dingtalk.clientSecret,
|
| 109 |
+
}),
|
| 110 |
+
})
|
| 111 |
+
const data = await res.json().catch(() => null) as { accessToken?: string; expireIn?: number; message?: string } | null
|
| 112 |
+
if (!res.ok || !data?.accessToken) {
|
| 113 |
+
throw new Error(data?.message || `accessToken request failed: ${res.status}`)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
accessTokenCache = {
|
| 117 |
+
token: data.accessToken,
|
| 118 |
+
expiresAt: now + Number(data.expireIn ?? 7200) * 1000,
|
| 119 |
+
}
|
| 120 |
+
return data.accessToken
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
async function sendText(chatId: string, text: string): Promise<void> {
|
| 124 |
+
const sessionWebhook = sessionWebhooks.get(chatId)
|
| 125 |
+
if (!sessionWebhook) {
|
| 126 |
+
console.warn(`[DingTalk] Missing sessionWebhook for ${chatId}; cannot send response`)
|
| 127 |
+
return
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const token = await getAccessToken()
|
| 131 |
+
for (const chunk of splitMessage(text, 3500)) {
|
| 132 |
+
const res = await fetch(sessionWebhook, {
|
| 133 |
+
method: 'POST',
|
| 134 |
+
headers: {
|
| 135 |
+
'Content-Type': 'application/json',
|
| 136 |
+
'x-acs-dingtalk-access-token': token,
|
| 137 |
+
},
|
| 138 |
+
body: JSON.stringify({
|
| 139 |
+
msgtype: 'markdown',
|
| 140 |
+
markdown: {
|
| 141 |
+
title: 'Claude Code',
|
| 142 |
+
text: chunk,
|
| 143 |
+
},
|
| 144 |
+
}),
|
| 145 |
+
})
|
| 146 |
+
if (!res.ok) {
|
| 147 |
+
const body = await res.text().catch(() => '')
|
| 148 |
+
console.warn(`[DingTalk] sendText failed: ${res.status} ${body}`)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function getAiCardBuffer(chatId: string): MessageBuffer {
|
| 154 |
+
let buffer = aiCardBuffers.get(chatId)
|
| 155 |
+
if (!buffer) {
|
| 156 |
+
buffer = new MessageBuffer(
|
| 157 |
+
async (text, isComplete) => flushToAiCard(chatId, text, isComplete),
|
| 158 |
+
1200,
|
| 159 |
+
200,
|
| 160 |
+
)
|
| 161 |
+
aiCardBuffers.set(chatId, buffer)
|
| 162 |
+
}
|
| 163 |
+
return buffer
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function getOrCreateAiCard(chatId: string): Promise<DingTalkAiCardInstance | null> | null {
|
| 167 |
+
const target = aiCardTargets.get(chatId)
|
| 168 |
+
if (!target) return null
|
| 169 |
+
|
| 170 |
+
let card = streamingCards.get(chatId)
|
| 171 |
+
if (!card) {
|
| 172 |
+
card = aiCards.createForTarget(target)
|
| 173 |
+
streamingCards.set(chatId, card)
|
| 174 |
+
}
|
| 175 |
+
return card
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
async function flushToAiCard(chatId: string, newText: string, isComplete: boolean): Promise<void> {
|
| 179 |
+
const fullText = (streamingCardText.get(chatId) ?? '') + newText
|
| 180 |
+
streamingCardText.set(chatId, fullText)
|
| 181 |
+
if (!fullText.trim()) return
|
| 182 |
+
|
| 183 |
+
const cardPromise = getOrCreateAiCard(chatId)
|
| 184 |
+
const card = cardPromise ? await cardPromise : null
|
| 185 |
+
if (!card) {
|
| 186 |
+
if (isComplete) await sendText(chatId, fullText)
|
| 187 |
+
return
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
try {
|
| 191 |
+
if (isComplete) {
|
| 192 |
+
await aiCards.finish(card, fullText)
|
| 193 |
+
streamingCards.delete(chatId)
|
| 194 |
+
streamingCardText.delete(chatId)
|
| 195 |
+
aiCardBuffers.get(chatId)?.reset()
|
| 196 |
+
aiCardBuffers.delete(chatId)
|
| 197 |
+
} else {
|
| 198 |
+
await aiCards.stream(card, `${fullText} ▍`, false)
|
| 199 |
+
}
|
| 200 |
+
} catch (err) {
|
| 201 |
+
console.warn('[DingTalk][AICard] stream failed, falling back to markdown:', err instanceof Error ? err.message : err)
|
| 202 |
+
streamingCards.delete(chatId)
|
| 203 |
+
if (isComplete) await sendText(chatId, fullText)
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function clearTransientChatState(chatId: string): void {
|
| 208 |
+
resetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText }, chatId)
|
| 209 |
+
clearPendingPermissions(chatId)
|
| 210 |
+
const runtime = getRuntimeState(chatId)
|
| 211 |
+
runtime.state = 'idle'
|
| 212 |
+
runtime.verb = undefined
|
| 213 |
+
runtime.pendingPermissionCount = 0
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function clearPendingPermissions(chatId: string): void {
|
| 217 |
+
const pending = pendingPermissions.get(chatId)
|
| 218 |
+
if (pending) {
|
| 219 |
+
for (const requestId of pending) pendingPermissionChats.delete(requestId)
|
| 220 |
+
}
|
| 221 |
+
pendingPermissions.delete(chatId)
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
|
| 225 |
+
const stored = sessionStore.get(chatId)
|
| 226 |
+
if (!stored) return null
|
| 227 |
+
|
| 228 |
+
if (!bridge.hasSession(chatId)) {
|
| 229 |
+
bridge.connectSession(chatId, stored.sessionId)
|
| 230 |
+
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
| 231 |
+
const opened = await bridge.waitForOpen(chatId)
|
| 232 |
+
if (!opened) return null
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return stored
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
async function buildStatusText(chatId: string): Promise<string> {
|
| 239 |
+
const stored = await ensureExistingSession(chatId)
|
| 240 |
+
if (!stored) return formatImStatus(null)
|
| 241 |
+
|
| 242 |
+
const runtime = getRuntimeState(chatId)
|
| 243 |
+
let projectName = path.basename(stored.workDir) || stored.workDir
|
| 244 |
+
let branch: string | null = null
|
| 245 |
+
|
| 246 |
+
try {
|
| 247 |
+
const gitInfo = await httpClient.getGitInfo(stored.sessionId)
|
| 248 |
+
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
|
| 249 |
+
branch = gitInfo.branch
|
| 250 |
+
} catch {
|
| 251 |
+
// Status should still be useful when git lookup fails.
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
let taskCounts:
|
| 255 |
+
| {
|
| 256 |
+
total: number
|
| 257 |
+
pending: number
|
| 258 |
+
inProgress: number
|
| 259 |
+
completed: number
|
| 260 |
+
}
|
| 261 |
+
| undefined
|
| 262 |
+
|
| 263 |
+
try {
|
| 264 |
+
const tasks = await httpClient.getTasksForSession(stored.sessionId)
|
| 265 |
+
if (tasks.length > 0) {
|
| 266 |
+
taskCounts = {
|
| 267 |
+
total: tasks.length,
|
| 268 |
+
pending: tasks.filter((task) => task.status === 'pending').length,
|
| 269 |
+
inProgress: tasks.filter((task) => task.status === 'in_progress').length,
|
| 270 |
+
completed: tasks.filter((task) => task.status === 'completed').length,
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
} catch {
|
| 274 |
+
// Ignore task lookup failures.
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return formatImStatus({
|
| 278 |
+
sessionId: stored.sessionId,
|
| 279 |
+
projectName,
|
| 280 |
+
branch,
|
| 281 |
+
model: runtime.model,
|
| 282 |
+
state: runtime.state,
|
| 283 |
+
verb: runtime.verb,
|
| 284 |
+
pendingPermissionCount: runtime.pendingPermissionCount,
|
| 285 |
+
taskCounts,
|
| 286 |
+
})
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function ensureSession(chatId: string): Promise<boolean> {
|
| 290 |
+
if (bridge.hasSession(chatId)) return true
|
| 291 |
+
|
| 292 |
+
const stored = sessionStore.get(chatId)
|
| 293 |
+
if (stored) {
|
| 294 |
+
bridge.connectSession(chatId, stored.sessionId)
|
| 295 |
+
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
| 296 |
+
return await bridge.waitForOpen(chatId)
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return await createSessionForChat(chatId, defaultWorkDir)
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
| 303 |
+
try {
|
| 304 |
+
bridge.resetSession(chatId)
|
| 305 |
+
clearTransientChatState(chatId)
|
| 306 |
+
|
| 307 |
+
const sessionId = await httpClient.createSession(workDir)
|
| 308 |
+
sessionStore.set(chatId, sessionId, workDir)
|
| 309 |
+
bridge.connectSession(chatId, sessionId)
|
| 310 |
+
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
| 311 |
+
const opened = await bridge.waitForOpen(chatId)
|
| 312 |
+
if (!opened) {
|
| 313 |
+
await sendText(chatId, '⚠️ 连接服务器超时,请重试。')
|
| 314 |
+
return false
|
| 315 |
+
}
|
| 316 |
+
return true
|
| 317 |
+
} catch (err) {
|
| 318 |
+
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
|
| 319 |
+
return false
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function formatProjectList(projects: RecentProject[]): string {
|
| 324 |
+
const lines = projects.slice(0, 10).map((project, index) => {
|
| 325 |
+
const branch = project.branch ? ` (${project.branch})` : ''
|
| 326 |
+
return `${index + 1}. **${project.projectName}**${branch}\n ${project.realPath}`
|
| 327 |
+
})
|
| 328 |
+
return `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n也可以发送 /new <编号或名称>`
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
async function showProjectPicker(chatId: string): Promise<void> {
|
| 332 |
+
try {
|
| 333 |
+
const projects = await httpClient.listRecentProjects()
|
| 334 |
+
if (projects.length === 0) {
|
| 335 |
+
await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
|
| 336 |
+
return
|
| 337 |
+
}
|
| 338 |
+
pendingProjectSelection.set(chatId, true)
|
| 339 |
+
await sendText(chatId, formatProjectList(projects))
|
| 340 |
+
} catch (err) {
|
| 341 |
+
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
async function startNewSession(chatId: string, query?: string): Promise<void> {
|
| 346 |
+
bridge.resetSession(chatId)
|
| 347 |
+
sessionStore.delete(chatId)
|
| 348 |
+
clearTransientChatState(chatId)
|
| 349 |
+
pendingProjectSelection.delete(chatId)
|
| 350 |
+
runtimeStates.delete(chatId)
|
| 351 |
+
|
| 352 |
+
if (query) {
|
| 353 |
+
try {
|
| 354 |
+
const { project, ambiguous } = await httpClient.matchProject(query)
|
| 355 |
+
if (project) {
|
| 356 |
+
const ok = await createSessionForChat(chatId, project.realPath)
|
| 357 |
+
if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
|
| 358 |
+
return
|
| 359 |
+
}
|
| 360 |
+
if (ambiguous) {
|
| 361 |
+
const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n')
|
| 362 |
+
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
|
| 363 |
+
return
|
| 364 |
+
}
|
| 365 |
+
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
|
| 366 |
+
} catch (err) {
|
| 367 |
+
await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
|
| 368 |
+
}
|
| 369 |
+
return
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const ok = await createSessionForChat(chatId, defaultWorkDir)
|
| 373 |
+
if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
| 377 |
+
const runtime = getRuntimeState(chatId)
|
| 378 |
+
|
| 379 |
+
switch (msg.type) {
|
| 380 |
+
case 'connected':
|
| 381 |
+
break
|
| 382 |
+
case 'status':
|
| 383 |
+
runtime.state = msg.state
|
| 384 |
+
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
|
| 385 |
+
break
|
| 386 |
+
case 'content_start':
|
| 387 |
+
if (msg.blockType === 'text') {
|
| 388 |
+
runtime.state = 'streaming'
|
| 389 |
+
}
|
| 390 |
+
if (msg.blockType === 'tool_use') runtime.state = 'tool_executing'
|
| 391 |
+
break
|
| 392 |
+
case 'content_delta':
|
| 393 |
+
if (typeof msg.text === 'string' && msg.text) getAiCardBuffer(chatId).append(msg.text)
|
| 394 |
+
break
|
| 395 |
+
case 'tool_use_complete':
|
| 396 |
+
runtime.state = 'streaming'
|
| 397 |
+
break
|
| 398 |
+
case 'permission_request': {
|
| 399 |
+
await sendPermissionRequest(chatId, msg)
|
| 400 |
+
break
|
| 401 |
+
}
|
| 402 |
+
case 'message_complete':
|
| 403 |
+
runtime.state = 'idle'
|
| 404 |
+
runtime.verb = undefined
|
| 405 |
+
await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
|
| 406 |
+
break
|
| 407 |
+
case 'error':
|
| 408 |
+
runtime.state = 'idle'
|
| 409 |
+
runtime.verb = undefined
|
| 410 |
+
aiCardBuffers.get(chatId)?.reset()
|
| 411 |
+
streamingCards.delete(chatId)
|
| 412 |
+
streamingCardText.delete(chatId)
|
| 413 |
+
await sendText(chatId, `❌ ${msg.message}`)
|
| 414 |
+
break
|
| 415 |
+
case 'system_notification':
|
| 416 |
+
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
|
| 417 |
+
const model = (msg.data as Record<string, unknown>).model
|
| 418 |
+
if (typeof model === 'string' && model.trim()) runtime.model = model
|
| 419 |
+
}
|
| 420 |
+
break
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promise<void> {
|
| 425 |
+
const runtime = getRuntimeState(chatId)
|
| 426 |
+
runtime.pendingPermissionCount += 1
|
| 427 |
+
runtime.state = 'permission_pending'
|
| 428 |
+
await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId)
|
| 429 |
+
|
| 430 |
+
const set = pendingPermissions.get(chatId) ?? new Set<string>()
|
| 431 |
+
set.add(msg.requestId)
|
| 432 |
+
pendingPermissions.set(chatId, set)
|
| 433 |
+
pendingPermissionChats.set(msg.requestId, chatId)
|
| 434 |
+
|
| 435 |
+
const requestText = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
|
| 436 |
+
const instructions = formatPermissionInstructions(msg.requestId)
|
| 437 |
+
const templateId = config.dingtalk.permissionCardTemplateId.trim()
|
| 438 |
+
const target = aiCardTargets.get(chatId)
|
| 439 |
+
|
| 440 |
+
if (templateId && target) {
|
| 441 |
+
const card = await aiCards.createForTarget(target, {
|
| 442 |
+
cardTemplateId: templateId,
|
| 443 |
+
outTrackId: `permission_${msg.requestId}`,
|
| 444 |
+
callbackRouteKey: DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE,
|
| 445 |
+
cardParamMap: buildDingTalkPermissionCardParams(msg.toolName, msg.input, msg.requestId),
|
| 446 |
+
})
|
| 447 |
+
if (card) {
|
| 448 |
+
await sendText(chatId, `${requestText}\n\n已发送钉钉权限卡片;如果卡片不可见,也可以${instructions}`)
|
| 449 |
+
return
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
await sendText(chatId, `${requestText}\n\n${instructions}`)
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
function handlePermissionCommand(chatId: string, text: string): boolean {
|
| 457 |
+
const decision = parsePermissionCommand(text, pendingPermissions.get(chatId))
|
| 458 |
+
if (!decision) return false
|
| 459 |
+
|
| 460 |
+
const sent = applyPermissionDecision(chatId, decision)
|
| 461 |
+
if (!sent) return true
|
| 462 |
+
|
| 463 |
+
void sendText(chatId, formatPermissionDecisionStatus(decision))
|
| 464 |
+
return true
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function applyPermissionDecision(chatId: string, decision: PermissionDecision): boolean {
|
| 468 |
+
const { requestId, allowed, rule } = decision
|
| 469 |
+
const pending = pendingPermissions.get(chatId)
|
| 470 |
+
if (!pending?.has(requestId)) {
|
| 471 |
+
void sendText(chatId, `未找到待确认的权限请求:${requestId}`)
|
| 472 |
+
return false
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule)
|
| 476 |
+
if (!sent) {
|
| 477 |
+
void sendText(chatId, '权限响应发送失败,请检查会话状态。')
|
| 478 |
+
return false
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
pending.delete(requestId)
|
| 482 |
+
pendingPermissionChats.delete(requestId)
|
| 483 |
+
const runtime = getRuntimeState(chatId)
|
| 484 |
+
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
|
| 485 |
+
return sent
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise<void> {
|
| 489 |
+
enqueue(chatId, async () => {
|
| 490 |
+
const trimmed = text.trim()
|
| 491 |
+
const hasAttachments = attachments.length > 0
|
| 492 |
+
|
| 493 |
+
if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
|
| 494 |
+
|
| 495 |
+
if (!hasAttachments && pendingProjectSelection.has(chatId)) {
|
| 496 |
+
if (trimmed) await startNewSession(chatId, trimmed)
|
| 497 |
+
return
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) {
|
| 501 |
+
const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
|
| 502 |
+
await startNewSession(chatId, arg || undefined)
|
| 503 |
+
return
|
| 504 |
+
}
|
| 505 |
+
if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
|
| 506 |
+
await sendText(chatId, formatImHelp())
|
| 507 |
+
return
|
| 508 |
+
}
|
| 509 |
+
if (!hasAttachments && (trimmed === '/status' || trimmed === '状态')) {
|
| 510 |
+
await sendText(chatId, await buildStatusText(chatId))
|
| 511 |
+
return
|
| 512 |
+
}
|
| 513 |
+
if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) {
|
| 514 |
+
const stored = await ensureExistingSession(chatId)
|
| 515 |
+
if (!stored) {
|
| 516 |
+
await sendText(chatId, formatImStatus(null))
|
| 517 |
+
return
|
| 518 |
+
}
|
| 519 |
+
clearTransientChatState(chatId)
|
| 520 |
+
if (!bridge.sendUserMessage(chatId, '/clear')) {
|
| 521 |
+
await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
|
| 522 |
+
return
|
| 523 |
+
}
|
| 524 |
+
await sendText(chatId, '🧹 已清空当前会话上下文。')
|
| 525 |
+
return
|
| 526 |
+
}
|
| 527 |
+
if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) {
|
| 528 |
+
const stored = await ensureExistingSession(chatId)
|
| 529 |
+
if (!stored) {
|
| 530 |
+
await sendText(chatId, formatImStatus(null))
|
| 531 |
+
return
|
| 532 |
+
}
|
| 533 |
+
bridge.sendStopGeneration(chatId)
|
| 534 |
+
await sendText(chatId, '⏹ 已发送停止信号。')
|
| 535 |
+
return
|
| 536 |
+
}
|
| 537 |
+
if (!hasAttachments && (trimmed === '/projects' || trimmed === '项目列表')) {
|
| 538 |
+
await showProjectPicker(chatId)
|
| 539 |
+
return
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
const ready = await ensureSession(chatId)
|
| 543 |
+
if (!ready) return
|
| 544 |
+
const effectiveText = trimmed || (attachments.length > 0 ? '(用户发送了附件)' : '')
|
| 545 |
+
if (!effectiveText && attachments.length === 0) return
|
| 546 |
+
if (!bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)) {
|
| 547 |
+
await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
| 548 |
+
}
|
| 549 |
+
})
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
async function handleRobotMessage(data: DingTalkRobotMessage): Promise<void> {
|
| 553 |
+
if (!isDingTalkDirectMessage(data)) return
|
| 554 |
+
|
| 555 |
+
const chatId = getDingTalkChatId(data)
|
| 556 |
+
const userId = getDingTalkSenderId(data)
|
| 557 |
+
const text = extractDingTalkText(data)
|
| 558 |
+
const mediaCandidates = extractDingTalkAttachments(data)
|
| 559 |
+
if (!chatId || !userId || (!text && mediaCandidates.length === 0)) return
|
| 560 |
+
|
| 561 |
+
if (data.sessionWebhook) sessionWebhooks.set(chatId, data.sessionWebhook)
|
| 562 |
+
|
| 563 |
+
if (!isAllowedUser('dingtalk', userId)) {
|
| 564 |
+
const success = tryPair(text, { userId, displayName: data.senderNick || 'DingTalk User' }, 'dingtalk')
|
| 565 |
+
await sendText(
|
| 566 |
+
chatId,
|
| 567 |
+
success
|
| 568 |
+
? '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。发送 /help 查看可用命令。'
|
| 569 |
+
: '🔒 未授权。请先在 Claude Code 桌面端完成钉钉扫码绑定,再生成 IM 配对码后发送给我。',
|
| 570 |
+
)
|
| 571 |
+
return
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
aiCardTargets.set(chatId, { type: 'user', userId })
|
| 575 |
+
const attachments = await collectAttachments(chatId, mediaCandidates)
|
| 576 |
+
await routeUserMessage(chatId, text, attachments)
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
async function handleCardCallback(raw: unknown): Promise<void> {
|
| 580 |
+
const action = parseDingTalkPermissionCardAction(raw)
|
| 581 |
+
if (!action) return
|
| 582 |
+
|
| 583 |
+
const chatId = action.chatId && pendingPermissions.has(action.chatId)
|
| 584 |
+
? action.chatId
|
| 585 |
+
: pendingPermissionChats.get(action.requestId)
|
| 586 |
+
if (!chatId) {
|
| 587 |
+
console.warn(`[DingTalk][Card] permission request not found: ${action.requestId}`)
|
| 588 |
+
return
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
if (applyPermissionDecision(chatId, action)) {
|
| 592 |
+
await sendText(chatId, formatPermissionDecisionStatus(action))
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
async function collectAttachments(
|
| 597 |
+
chatId: string,
|
| 598 |
+
candidates: ReturnType<typeof extractDingTalkAttachments>,
|
| 599 |
+
): Promise<AttachmentRef[]> {
|
| 600 |
+
if (candidates.length === 0) return []
|
| 601 |
+
const stored = sessionStore.get(chatId)
|
| 602 |
+
const sessionId = stored?.sessionId ?? chatId
|
| 603 |
+
let token: string
|
| 604 |
+
try {
|
| 605 |
+
token = await getAccessToken()
|
| 606 |
+
} catch (err) {
|
| 607 |
+
console.error('[DingTalk] access token for attachment download failed:', err)
|
| 608 |
+
await sendText(chatId, '📎 附件下载授权失败,请稍后重试。')
|
| 609 |
+
return []
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
const settled = await Promise.allSettled(
|
| 613 |
+
candidates.map((candidate) =>
|
| 614 |
+
media.downloadCandidate(candidate, sessionId, {
|
| 615 |
+
clientId: config.dingtalk.clientId,
|
| 616 |
+
accessToken: token,
|
| 617 |
+
}),
|
| 618 |
+
),
|
| 619 |
+
)
|
| 620 |
+
const attachments: AttachmentRef[] = []
|
| 621 |
+
let failures = 0
|
| 622 |
+
for (const result of settled) {
|
| 623 |
+
if (result.status === 'rejected') {
|
| 624 |
+
failures += 1
|
| 625 |
+
console.error('[DingTalk] media download failed:', result.reason)
|
| 626 |
+
continue
|
| 627 |
+
}
|
| 628 |
+
const local = result.value
|
| 629 |
+
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
|
| 630 |
+
if (!check.ok) {
|
| 631 |
+
await sendText(chatId, check.hint)
|
| 632 |
+
continue
|
| 633 |
+
}
|
| 634 |
+
if (local.kind === 'image') {
|
| 635 |
+
attachments.push({
|
| 636 |
+
type: 'image',
|
| 637 |
+
name: local.name,
|
| 638 |
+
data: local.buffer.toString('base64'),
|
| 639 |
+
mimeType: local.mimeType,
|
| 640 |
+
})
|
| 641 |
+
} else {
|
| 642 |
+
attachments.push({
|
| 643 |
+
type: 'file',
|
| 644 |
+
name: local.name,
|
| 645 |
+
path: local.path,
|
| 646 |
+
mimeType: local.mimeType,
|
| 647 |
+
})
|
| 648 |
+
}
|
| 649 |
+
}
|
| 650 |
+
if (failures > 0) {
|
| 651 |
+
await sendText(
|
| 652 |
+
chatId,
|
| 653 |
+
failures === candidates.length ? '📎 附件下载失败,请稍后重试。' : `📎 ${failures} 个附件下载失败,已跳过。`,
|
| 654 |
+
)
|
| 655 |
+
}
|
| 656 |
+
return attachments
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
async function start(): Promise<void> {
|
| 660 |
+
const client = new DWClient({
|
| 661 |
+
clientId: config.dingtalk.clientId,
|
| 662 |
+
clientSecret: config.dingtalk.clientSecret,
|
| 663 |
+
endpoint: config.dingtalk.endpoint,
|
| 664 |
+
autoReconnect: true,
|
| 665 |
+
keepAlive: true,
|
| 666 |
+
} as any)
|
| 667 |
+
|
| 668 |
+
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
| 669 |
+
const messageId = res.headers?.messageId
|
| 670 |
+
if (messageId) {
|
| 671 |
+
client.socketCallBackResponse(messageId, { success: true })
|
| 672 |
+
if (!dedup.tryRecord(`header:${messageId}`)) return
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
const data = parseDingTalkPayload(res.data)
|
| 676 |
+
if (!data) return
|
| 677 |
+
if (data.msgId && !dedup.tryRecord(`body:${data.msgId}`)) return
|
| 678 |
+
|
| 679 |
+
await handleRobotMessage(data)
|
| 680 |
+
})
|
| 681 |
+
|
| 682 |
+
client.registerCallbackListener(TOPIC_CARD, async (res: any) => {
|
| 683 |
+
const messageId = res.headers?.messageId
|
| 684 |
+
if (messageId) {
|
| 685 |
+
client.socketCallBackResponse(messageId, { success: true })
|
| 686 |
+
if (!dedup.tryRecord(`card:${messageId}`)) return
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
await handleCardCallback(res.data ?? res)
|
| 690 |
+
})
|
| 691 |
+
|
| 692 |
+
await client.connect()
|
| 693 |
+
console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`)
|
| 694 |
+
|
| 695 |
+
const shutdown = async () => {
|
| 696 |
+
console.log('[DingTalk] Shutting down...')
|
| 697 |
+
bridge.destroy()
|
| 698 |
+
dedup.destroy()
|
| 699 |
+
try {
|
| 700 |
+
await client.disconnect()
|
| 701 |
+
} catch {
|
| 702 |
+
// ignore
|
| 703 |
+
}
|
| 704 |
+
process.exit(0)
|
| 705 |
+
}
|
| 706 |
+
process.once('SIGINT', () => void shutdown())
|
| 707 |
+
process.once('SIGTERM', () => void shutdown())
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
start().catch((err) => {
|
| 711 |
+
console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err)
|
| 712 |
+
process.exit(1)
|
| 713 |
+
})
|
adapters/dingtalk/media.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path'
|
| 2 |
+
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
| 3 |
+
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
|
| 4 |
+
import type { DingTalkAttachmentCandidate } from './helpers.js'
|
| 5 |
+
|
| 6 |
+
const DINGTALK_API = 'https://api.dingtalk.com'
|
| 7 |
+
|
| 8 |
+
export class DingTalkMediaService {
|
| 9 |
+
constructor(private readonly store: AttachmentStore) {}
|
| 10 |
+
|
| 11 |
+
async downloadCandidate(
|
| 12 |
+
candidate: DingTalkAttachmentCandidate,
|
| 13 |
+
sessionId: string,
|
| 14 |
+
opts: { clientId: string; accessToken: string },
|
| 15 |
+
): Promise<LocalAttachment> {
|
| 16 |
+
const downloadUrl = candidate.url || await this.resolveDownloadUrl(candidate.downloadCode, opts)
|
| 17 |
+
if (!downloadUrl) throw new Error('DingTalk media item is missing a download URL')
|
| 18 |
+
|
| 19 |
+
const resp = await fetch(downloadUrl)
|
| 20 |
+
if (!resp.ok) {
|
| 21 |
+
throw new Error(`DingTalk media download failed: ${resp.status} ${resp.statusText}`)
|
| 22 |
+
}
|
| 23 |
+
const buffer = Buffer.from(await resp.arrayBuffer())
|
| 24 |
+
const contentType = resp.headers.get('content-type') || inferMime(candidate.fileName, candidate.kind)
|
| 25 |
+
const name = candidate.fileName || buildImageName(contentType)
|
| 26 |
+
const target = this.store.resolvePath('dingtalk', sessionId, name)
|
| 27 |
+
const savedPath = await this.store.write(target, buffer)
|
| 28 |
+
|
| 29 |
+
return {
|
| 30 |
+
kind: candidate.kind,
|
| 31 |
+
name,
|
| 32 |
+
path: savedPath,
|
| 33 |
+
buffer,
|
| 34 |
+
size: buffer.length,
|
| 35 |
+
mimeType: contentType,
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
private async resolveDownloadUrl(
|
| 40 |
+
downloadCode: string | undefined,
|
| 41 |
+
opts: { clientId: string; accessToken: string },
|
| 42 |
+
): Promise<string | null> {
|
| 43 |
+
if (!downloadCode) return null
|
| 44 |
+
const resp = await fetch(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, {
|
| 45 |
+
method: 'POST',
|
| 46 |
+
headers: {
|
| 47 |
+
'Content-Type': 'application/json',
|
| 48 |
+
'x-acs-dingtalk-access-token': opts.accessToken,
|
| 49 |
+
},
|
| 50 |
+
body: JSON.stringify({
|
| 51 |
+
downloadCode,
|
| 52 |
+
robotCode: opts.clientId,
|
| 53 |
+
}),
|
| 54 |
+
})
|
| 55 |
+
const body = await resp.json().catch(() => null) as { downloadUrl?: string; message?: string } | null
|
| 56 |
+
if (!resp.ok || !body?.downloadUrl) {
|
| 57 |
+
throw new Error(body?.message || `DingTalk downloadCode exchange failed: ${resp.status}`)
|
| 58 |
+
}
|
| 59 |
+
return body.downloadUrl
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function buildImageName(mime?: string): string {
|
| 64 |
+
const ext = mime?.includes('png')
|
| 65 |
+
? '.png'
|
| 66 |
+
: mime?.includes('gif')
|
| 67 |
+
? '.gif'
|
| 68 |
+
: mime?.includes('webp')
|
| 69 |
+
? '.webp'
|
| 70 |
+
: '.jpg'
|
| 71 |
+
return `dingtalk-image-${Date.now()}${ext}`
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function inferMime(fileName: string | undefined, kind: 'image' | 'file'): string {
|
| 75 |
+
if (kind === 'image') return 'image/jpeg'
|
| 76 |
+
const ext = path.extname(fileName || '').toLowerCase()
|
| 77 |
+
if (ext === '.pdf') return 'application/pdf'
|
| 78 |
+
if (ext === '.txt') return 'text/plain'
|
| 79 |
+
if (ext === '.png') return 'image/png'
|
| 80 |
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
| 81 |
+
if (ext === '.gif') return 'image/gif'
|
| 82 |
+
if (ext === '.webp') return 'image/webp'
|
| 83 |
+
return 'application/octet-stream'
|
| 84 |
+
}
|
adapters/dingtalk/permission-card.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { truncateInput } from '../common/format.js'
|
| 2 |
+
import { parsePermitCallbackData, type PermissionDecision } from '../common/permission.js'
|
| 3 |
+
|
| 4 |
+
export const DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE = 'permission'
|
| 5 |
+
|
| 6 |
+
export type DingTalkPermissionCardAction = PermissionDecision & {
|
| 7 |
+
outTrackId?: string
|
| 8 |
+
chatId?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function buildDingTalkPermissionCardParams(
|
| 12 |
+
toolName: string,
|
| 13 |
+
input: unknown,
|
| 14 |
+
requestId: string,
|
| 15 |
+
): Record<string, unknown> {
|
| 16 |
+
const allowValue = { action: 'permit', requestId, allowed: true }
|
| 17 |
+
const alwaysValue = { action: 'permit', requestId, allowed: true, rule: 'always' }
|
| 18 |
+
const denyValue = { action: 'permit', requestId, allowed: false }
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
title: 'Claude Code 需要权限确认',
|
| 22 |
+
toolName,
|
| 23 |
+
requestId,
|
| 24 |
+
inputPreview: truncateInput(input, 600),
|
| 25 |
+
allowText: '允许一次',
|
| 26 |
+
alwaysText: '永久允许',
|
| 27 |
+
denyText: '拒绝',
|
| 28 |
+
allowValue: JSON.stringify(allowValue),
|
| 29 |
+
alwaysValue: JSON.stringify(alwaysValue),
|
| 30 |
+
denyValue: JSON.stringify(denyValue),
|
| 31 |
+
permissionActions: JSON.stringify([
|
| 32 |
+
{ text: '允许一次', value: allowValue },
|
| 33 |
+
{ text: '永久允许', value: alwaysValue },
|
| 34 |
+
{ text: '拒绝', value: denyValue },
|
| 35 |
+
]),
|
| 36 |
+
sys_full_json_obj: JSON.stringify({
|
| 37 |
+
order: ['title', 'toolName', 'inputPreview'],
|
| 38 |
+
actions: ['allowValue', 'alwaysValue', 'denyValue'],
|
| 39 |
+
}),
|
| 40 |
+
config: JSON.stringify({ autoLayout: true }),
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function parseDingTalkPermissionCardAction(raw: unknown): DingTalkPermissionCardAction | null {
|
| 45 |
+
const root = parseMaybeJson(raw)
|
| 46 |
+
const values = collectValues(root)
|
| 47 |
+
|
| 48 |
+
for (const value of values) {
|
| 49 |
+
if (typeof value === 'string') {
|
| 50 |
+
const direct = parsePermitCallbackData(value)
|
| 51 |
+
if (direct) return direct
|
| 52 |
+
const parsed = parseMaybeJson(value)
|
| 53 |
+
if (parsed !== value) {
|
| 54 |
+
const nested = parseDingTalkPermissionCardAction(parsed)
|
| 55 |
+
if (nested) return nested
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const objects = values.filter(isRecord)
|
| 61 |
+
for (const obj of objects) {
|
| 62 |
+
const requestId = readString(obj, ['requestId', 'request_id', 'permissionRequestId'])
|
| 63 |
+
if (!requestId) continue
|
| 64 |
+
|
| 65 |
+
const action = readString(obj, ['action', 'actionType', 'decision', 'value', 'actionValue', 'command'])?.toLowerCase()
|
| 66 |
+
const allowed = readBoolean(obj, ['allowed', 'allow', 'approved'])
|
| 67 |
+
const rule = readString(obj, ['rule']) === 'always' ? 'always' : undefined
|
| 68 |
+
const outTrackId = readString(obj, ['outTrackId', 'cardInstanceId'])
|
| 69 |
+
const chatId = readString(obj, ['chatId', 'conversationId', 'openConversationId'])
|
| 70 |
+
|
| 71 |
+
if (allowed !== undefined) return { requestId, allowed, rule, outTrackId, chatId }
|
| 72 |
+
if (action && ['allow', 'yes', 'approve', 'approved', 'permit'].includes(action)) {
|
| 73 |
+
return { requestId, allowed: true, rule, outTrackId, chatId }
|
| 74 |
+
}
|
| 75 |
+
if (action && ['always', 'allow-always', 'approve-always'].includes(action)) {
|
| 76 |
+
return { requestId, allowed: true, rule: 'always', outTrackId, chatId }
|
| 77 |
+
}
|
| 78 |
+
if (action && ['deny', 'no', 'reject', 'rejected'].includes(action)) {
|
| 79 |
+
return { requestId, allowed: false, outTrackId, chatId }
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return null
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function parseMaybeJson(value: unknown): unknown {
|
| 87 |
+
if (typeof value !== 'string') return value
|
| 88 |
+
const trimmed = value.trim()
|
| 89 |
+
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value
|
| 90 |
+
try {
|
| 91 |
+
return JSON.parse(trimmed)
|
| 92 |
+
} catch {
|
| 93 |
+
return value
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function collectValues(value: unknown, seen = new Set<unknown>()): unknown[] {
|
| 98 |
+
const parsed = parseMaybeJson(value)
|
| 99 |
+
if (parsed && typeof parsed === 'object') {
|
| 100 |
+
if (seen.has(parsed)) return []
|
| 101 |
+
seen.add(parsed)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const values = [parsed]
|
| 105 |
+
if (Array.isArray(parsed)) {
|
| 106 |
+
for (const item of parsed) values.push(...collectValues(item, seen))
|
| 107 |
+
} else if (isRecord(parsed)) {
|
| 108 |
+
for (const item of Object.values(parsed)) values.push(...collectValues(item, seen))
|
| 109 |
+
}
|
| 110 |
+
return values
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 114 |
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function readString(obj: Record<string, unknown>, keys: string[]): string | undefined {
|
| 118 |
+
for (const key of keys) {
|
| 119 |
+
const value = obj[key]
|
| 120 |
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
| 121 |
+
}
|
| 122 |
+
return undefined
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function readBoolean(obj: Record<string, unknown>, keys: string[]): boolean | undefined {
|
| 126 |
+
for (const key of keys) {
|
| 127 |
+
const value = obj[key]
|
| 128 |
+
if (typeof value === 'boolean') return value
|
| 129 |
+
if (typeof value === 'string') {
|
| 130 |
+
if (/^(true|yes|allow|approve|permit)$/i.test(value)) return true
|
| 131 |
+
if (/^(false|no|deny|reject)$/i.test(value)) return false
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
return undefined
|
| 135 |
+
}
|
adapters/dingtalk/stream-state.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MessageBuffer } from '../common/message-buffer.js'
|
| 2 |
+
import type { DingTalkAiCardInstance } from './ai-card.js'
|
| 3 |
+
|
| 4 |
+
export type DingTalkStreamingState = {
|
| 5 |
+
aiCardBuffers: Map<string, MessageBuffer>
|
| 6 |
+
streamingCards: Map<string, Promise<DingTalkAiCardInstance | null>>
|
| 7 |
+
streamingCardText: Map<string, string>
|
| 8 |
+
finalize?: () => Promise<void>
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function resetDingTalkStreamingState(
|
| 12 |
+
state: DingTalkStreamingState,
|
| 13 |
+
chatId: string,
|
| 14 |
+
): void {
|
| 15 |
+
state.aiCardBuffers.get(chatId)?.reset()
|
| 16 |
+
state.aiCardBuffers.delete(chatId)
|
| 17 |
+
state.streamingCards.delete(chatId)
|
| 18 |
+
state.streamingCardText.delete(chatId)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export async function finishAndResetDingTalkStreamingState(
|
| 22 |
+
state: DingTalkStreamingState,
|
| 23 |
+
chatId: string,
|
| 24 |
+
): Promise<void> {
|
| 25 |
+
await state.aiCardBuffers.get(chatId)?.complete()
|
| 26 |
+
if (state.finalize && (state.streamingCards.has(chatId) || state.streamingCardText.has(chatId))) {
|
| 27 |
+
await state.finalize()
|
| 28 |
+
}
|
| 29 |
+
resetDingTalkStreamingState(state, chatId)
|
| 30 |
+
}
|
adapters/feishu/__tests__/card-errors.test.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* card-errors 单元测试
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { describe, it, expect } from 'bun:test'
|
| 6 |
+
import {
|
| 7 |
+
CARD_ERROR,
|
| 8 |
+
CARD_CONTENT_SUB_ERROR,
|
| 9 |
+
extractLarkApiCode,
|
| 10 |
+
extractSubCode,
|
| 11 |
+
parseCardApiError,
|
| 12 |
+
isCardRateLimitError,
|
| 13 |
+
isCardTableLimitError,
|
| 14 |
+
} from '../card-errors.js'
|
| 15 |
+
|
| 16 |
+
describe('extractLarkApiCode', () => {
|
| 17 |
+
it('从 err.code 直接提取', () => {
|
| 18 |
+
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
it('从 err.data.code 提取', () => {
|
| 22 |
+
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
it('从 err.response.data.code 提取(Axios 风格)', () => {
|
| 26 |
+
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
it('数字字符串被强制转成 number', () => {
|
| 30 |
+
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
|
| 34 |
+
const err = {
|
| 35 |
+
code: 1,
|
| 36 |
+
data: { code: 2 },
|
| 37 |
+
response: { data: { code: 3 } },
|
| 38 |
+
}
|
| 39 |
+
expect(extractLarkApiCode(err)).toBe(1)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
it('none → undefined', () => {
|
| 43 |
+
expect(extractLarkApiCode({})).toBeUndefined()
|
| 44 |
+
expect(extractLarkApiCode(null)).toBeUndefined()
|
| 45 |
+
expect(extractLarkApiCode(undefined)).toBeUndefined()
|
| 46 |
+
expect(extractLarkApiCode('just a string')).toBeUndefined()
|
| 47 |
+
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
it('非有限数字被忽略', () => {
|
| 51 |
+
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
|
| 52 |
+
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
|
| 53 |
+
})
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
describe('extractSubCode', () => {
|
| 57 |
+
it('识别标准的 ErrCode: 11310', () => {
|
| 58 |
+
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
|
| 59 |
+
expect(extractSubCode(msg)).toBe(11310)
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
it('无 ErrCode 时返回 null', () => {
|
| 63 |
+
expect(extractSubCode('random error message')).toBeNull()
|
| 64 |
+
expect(extractSubCode('')).toBeNull()
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
it('ErrCode 大小写容错(冒号后多空格)', () => {
|
| 68 |
+
expect(extractSubCode('ErrCode: 42')).toBe(42)
|
| 69 |
+
})
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
describe('parseCardApiError', () => {
|
| 73 |
+
it('从 SDK 风格错误提取完整结构', () => {
|
| 74 |
+
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
|
| 75 |
+
const parsed = parseCardApiError(err)
|
| 76 |
+
expect(parsed).toEqual({
|
| 77 |
+
code: 230099,
|
| 78 |
+
subCode: 11310,
|
| 79 |
+
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
|
| 80 |
+
})
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
it('从 Axios 风格错误(response.data.msg)提取', () => {
|
| 84 |
+
const err = {
|
| 85 |
+
response: {
|
| 86 |
+
data: {
|
| 87 |
+
code: 230020,
|
| 88 |
+
msg: 'rate limited',
|
| 89 |
+
},
|
| 90 |
+
},
|
| 91 |
+
}
|
| 92 |
+
const parsed = parseCardApiError(err)
|
| 93 |
+
expect(parsed?.code).toBe(230020)
|
| 94 |
+
expect(parsed?.errMsg).toBe('rate limited')
|
| 95 |
+
expect(parsed?.subCode).toBeNull()
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
it('无 code 时返回 null', () => {
|
| 99 |
+
expect(parseCardApiError({})).toBeNull()
|
| 100 |
+
expect(parseCardApiError(null)).toBeNull()
|
| 101 |
+
expect(parseCardApiError('string')).toBeNull()
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
it('有 code 无 msg 时 errMsg 为空字符串', () => {
|
| 105 |
+
const parsed = parseCardApiError({ code: 230020 })
|
| 106 |
+
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
it('fallback 到 err.message', () => {
|
| 110 |
+
const err = Object.assign(new Error('fallback text'), { code: 230099 })
|
| 111 |
+
const parsed = parseCardApiError(err)
|
| 112 |
+
expect(parsed?.errMsg).toBe('fallback text')
|
| 113 |
+
})
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
describe('isCardRateLimitError', () => {
|
| 117 |
+
it('识别 230020', () => {
|
| 118 |
+
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
it('识别 Axios 风格 230020', () => {
|
| 122 |
+
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
it('不匹配其他 code', () => {
|
| 126 |
+
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
|
| 127 |
+
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
it('非错误对象返回 false', () => {
|
| 131 |
+
expect(isCardRateLimitError(null)).toBe(false)
|
| 132 |
+
expect(isCardRateLimitError({})).toBe(false)
|
| 133 |
+
expect(isCardRateLimitError(new Error('random'))).toBe(false)
|
| 134 |
+
})
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
describe('isCardTableLimitError', () => {
|
| 138 |
+
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
|
| 139 |
+
|
| 140 |
+
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
|
| 141 |
+
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
|
| 142 |
+
expect(isCardTableLimitError(err)).toBe(true)
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
it('从 Axios 风格的 response.data 匹配', () => {
|
| 146 |
+
const err = {
|
| 147 |
+
response: {
|
| 148 |
+
data: {
|
| 149 |
+
code: 230099,
|
| 150 |
+
msg: validMsg,
|
| 151 |
+
},
|
| 152 |
+
},
|
| 153 |
+
}
|
| 154 |
+
expect(isCardTableLimitError(err)).toBe(true)
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
|
| 158 |
+
const err = {
|
| 159 |
+
code: CARD_ERROR.CARD_CONTENT_FAILED,
|
| 160 |
+
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
|
| 161 |
+
}
|
| 162 |
+
expect(isCardTableLimitError(err)).toBe(false)
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
it('code 不是 230099 → false', () => {
|
| 166 |
+
const err = { code: 230020, msg: validMsg }
|
| 167 |
+
expect(isCardTableLimitError(err)).toBe(false)
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
it('没有 subCode → false', () => {
|
| 171 |
+
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
|
| 172 |
+
expect(isCardTableLimitError(err)).toBe(false)
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
it('不区分 "table number" 的大小写', () => {
|
| 176 |
+
const err = {
|
| 177 |
+
code: 230099,
|
| 178 |
+
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
|
| 179 |
+
}
|
| 180 |
+
expect(isCardTableLimitError(err)).toBe(true)
|
| 181 |
+
})
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
describe('常量值', () => {
|
| 185 |
+
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
|
| 186 |
+
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
|
| 187 |
+
})
|
| 188 |
+
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
|
| 189 |
+
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
|
| 190 |
+
})
|
| 191 |
+
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
|
| 192 |
+
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
|
| 193 |
+
})
|
| 194 |
+
})
|
adapters/feishu/__tests__/cardkit.test.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* cardkit.ts 单元测试
|
| 3 |
+
*
|
| 4 |
+
* 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
|
| 5 |
+
* - 每个函数构造的 payload 结构
|
| 6 |
+
* - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
|
| 7 |
+
* - 缺失关键字段时抛错
|
| 8 |
+
* - sequence 正确传递
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect } from 'bun:test'
|
| 12 |
+
import {
|
| 13 |
+
createCardEntity,
|
| 14 |
+
sendCardAsMessage,
|
| 15 |
+
streamCardContent,
|
| 16 |
+
setCardStreamingMode,
|
| 17 |
+
updateCardKitCard,
|
| 18 |
+
CardKitApiError,
|
| 19 |
+
STREAMING_ELEMENT_ID,
|
| 20 |
+
} from '../cardkit.js'
|
| 21 |
+
import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
|
| 22 |
+
|
| 23 |
+
// ---------------------------------------------------------------------------
|
| 24 |
+
// Mock client factory
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
type MockCall = { api: string; args: any }
|
| 28 |
+
|
| 29 |
+
function makeMockClient(responses: Record<string, any>) {
|
| 30 |
+
const calls: MockCall[] = []
|
| 31 |
+
const recorder = (api: string, resp: any) => async (args: any) => {
|
| 32 |
+
calls.push({ api, args })
|
| 33 |
+
if (typeof resp === 'function') {
|
| 34 |
+
return resp(args)
|
| 35 |
+
}
|
| 36 |
+
return resp
|
| 37 |
+
}
|
| 38 |
+
const client: any = {
|
| 39 |
+
cardkit: {
|
| 40 |
+
v1: {
|
| 41 |
+
card: {
|
| 42 |
+
create: recorder('cardkit.v1.card.create', responses['card.create']),
|
| 43 |
+
settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
|
| 44 |
+
update: recorder('cardkit.v1.card.update', responses['card.update']),
|
| 45 |
+
},
|
| 46 |
+
cardElement: {
|
| 47 |
+
content: recorder(
|
| 48 |
+
'cardkit.v1.cardElement.content',
|
| 49 |
+
responses['cardElement.content'],
|
| 50 |
+
),
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
im: {
|
| 55 |
+
message: {
|
| 56 |
+
create: recorder('im.message.create', responses['im.message.create']),
|
| 57 |
+
reply: recorder('im.message.reply', responses['im.message.reply']),
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
}
|
| 61 |
+
return { client, calls }
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// ---------------------------------------------------------------------------
|
| 65 |
+
// createCardEntity
|
| 66 |
+
// ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
describe('createCardEntity', () => {
|
| 69 |
+
it('构造 card_json payload 并返回 card_id', async () => {
|
| 70 |
+
const { client, calls } = makeMockClient({
|
| 71 |
+
'card.create': {
|
| 72 |
+
code: 0,
|
| 73 |
+
data: { card_id: 'ck_abc_123' },
|
| 74 |
+
},
|
| 75 |
+
})
|
| 76 |
+
const card = { schema: '2.0', body: { elements: [] } }
|
| 77 |
+
const id = await createCardEntity(client, card)
|
| 78 |
+
|
| 79 |
+
expect(id).toBe('ck_abc_123')
|
| 80 |
+
expect(calls.length).toBe(1)
|
| 81 |
+
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
| 82 |
+
expect(calls[0]!.args.data.type).toBe('card_json')
|
| 83 |
+
// data.data 应当是 card 的 JSON 字符串
|
| 84 |
+
expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
|
| 88 |
+
const { client } = makeMockClient({
|
| 89 |
+
'card.create': { code: 0, card_id: 'top_level_id' },
|
| 90 |
+
})
|
| 91 |
+
const id = await createCardEntity(client, {})
|
| 92 |
+
expect(id).toBe('top_level_id')
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
it('non-zero code 抛 CardKitApiError', async () => {
|
| 96 |
+
const { client } = makeMockClient({
|
| 97 |
+
'card.create': { code: 230099, msg: 'something failed' },
|
| 98 |
+
})
|
| 99 |
+
await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
it('code=0 但缺 card_id 抛错', async () => {
|
| 103 |
+
const { client } = makeMockClient({
|
| 104 |
+
'card.create': { code: 0, data: {} },
|
| 105 |
+
})
|
| 106 |
+
await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
|
| 107 |
+
})
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
// ---------------------------------------------------------------------------
|
| 111 |
+
// sendCardAsMessage
|
| 112 |
+
// ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
describe('sendCardAsMessage', () => {
|
| 115 |
+
it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
|
| 116 |
+
const { client, calls } = makeMockClient({
|
| 117 |
+
'im.message.create': { data: { message_id: 'om_new_msg_1' } },
|
| 118 |
+
})
|
| 119 |
+
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
|
| 120 |
+
expect(mid).toBe('om_new_msg_1')
|
| 121 |
+
expect(calls.length).toBe(1)
|
| 122 |
+
expect(calls[0]!.api).toBe('im.message.create')
|
| 123 |
+
expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
|
| 124 |
+
expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
|
| 125 |
+
expect(calls[0]!.args.data.msg_type).toBe('interactive')
|
| 126 |
+
// content 格式: {"type":"card","data":{"card_id":"xxx"}}
|
| 127 |
+
const parsed = JSON.parse(calls[0]!.args.data.content)
|
| 128 |
+
expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
it('有 replyTo: 走 im.message.reply', async () => {
|
| 132 |
+
const { client, calls } = makeMockClient({
|
| 133 |
+
'im.message.reply': { data: { message_id: 'om_reply_1' } },
|
| 134 |
+
})
|
| 135 |
+
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
|
| 136 |
+
expect(mid).toBe('om_reply_1')
|
| 137 |
+
expect(calls.length).toBe(1)
|
| 138 |
+
expect(calls[0]!.api).toBe('im.message.reply')
|
| 139 |
+
expect(calls[0]!.args.path.message_id).toBe('om_parent')
|
| 140 |
+
const parsed = JSON.parse(calls[0]!.args.data.content)
|
| 141 |
+
expect(parsed.data.card_id).toBe('ck_id_xyz')
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
it('缺 message_id 抛错', async () => {
|
| 145 |
+
const { client } = makeMockClient({
|
| 146 |
+
'im.message.create': { data: {} },
|
| 147 |
+
})
|
| 148 |
+
await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
|
| 149 |
+
/missing message_id/,
|
| 150 |
+
)
|
| 151 |
+
})
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
// ---------------------------------------------------------------------------
|
| 155 |
+
// streamCardContent
|
| 156 |
+
// ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
describe('streamCardContent', () => {
|
| 159 |
+
it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
|
| 160 |
+
const { client, calls } = makeMockClient({
|
| 161 |
+
'cardElement.content': { code: 0 },
|
| 162 |
+
})
|
| 163 |
+
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
|
| 164 |
+
|
| 165 |
+
expect(calls.length).toBe(1)
|
| 166 |
+
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
|
| 167 |
+
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
|
| 168 |
+
expect(calls[0]!.args.path).toEqual({
|
| 169 |
+
card_id: 'ck_abc',
|
| 170 |
+
element_id: STREAMING_ELEMENT_ID,
|
| 171 |
+
})
|
| 172 |
+
})
|
| 173 |
+
|
| 174 |
+
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
|
| 175 |
+
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
it('230020 响应可被 isCardRateLimitError 识别', async () => {
|
| 179 |
+
const { client } = makeMockClient({
|
| 180 |
+
'cardElement.content': { code: 230020, msg: 'rate limited' },
|
| 181 |
+
})
|
| 182 |
+
try {
|
| 183 |
+
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
| 184 |
+
expect('should have thrown').toBe('but did not')
|
| 185 |
+
} catch (err) {
|
| 186 |
+
expect(err).toBeInstanceOf(CardKitApiError)
|
| 187 |
+
expect(isCardRateLimitError(err)).toBe(true)
|
| 188 |
+
}
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
|
| 192 |
+
const { client } = makeMockClient({
|
| 193 |
+
'cardElement.content': {
|
| 194 |
+
code: 230099,
|
| 195 |
+
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
|
| 196 |
+
},
|
| 197 |
+
})
|
| 198 |
+
try {
|
| 199 |
+
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
| 200 |
+
expect('should have thrown').toBe('but did not')
|
| 201 |
+
} catch (err) {
|
| 202 |
+
expect(err).toBeInstanceOf(CardKitApiError)
|
| 203 |
+
expect(isCardTableLimitError(err)).toBe(true)
|
| 204 |
+
}
|
| 205 |
+
})
|
| 206 |
+
})
|
| 207 |
+
|
| 208 |
+
// ---------------------------------------------------------------------------
|
| 209 |
+
// setCardStreamingMode
|
| 210 |
+
// ---------------------------------------------------------------------------
|
| 211 |
+
|
| 212 |
+
describe('setCardStreamingMode', () => {
|
| 213 |
+
it('streaming_mode=false + sequence 正确传递', async () => {
|
| 214 |
+
const { client, calls } = makeMockClient({
|
| 215 |
+
'card.settings': { code: 0 },
|
| 216 |
+
})
|
| 217 |
+
await setCardStreamingMode(client, 'ck_xxx', false, 99)
|
| 218 |
+
|
| 219 |
+
expect(calls.length).toBe(1)
|
| 220 |
+
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
|
| 221 |
+
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
|
| 222 |
+
expect(calls[0]!.args.data.sequence).toBe(99)
|
| 223 |
+
// settings 是 JSON 字符串
|
| 224 |
+
const settings = JSON.parse(calls[0]!.args.data.settings)
|
| 225 |
+
expect(settings).toEqual({ streaming_mode: false })
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
it('streaming_mode=true 也能工作', async () => {
|
| 229 |
+
const { client, calls } = makeMockClient({
|
| 230 |
+
'card.settings': { code: 0 },
|
| 231 |
+
})
|
| 232 |
+
await setCardStreamingMode(client, 'ck', true, 1)
|
| 233 |
+
const settings = JSON.parse(calls[0]!.args.data.settings)
|
| 234 |
+
expect(settings).toEqual({ streaming_mode: true })
|
| 235 |
+
})
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
// ---------------------------------------------------------------------------
|
| 239 |
+
// updateCardKitCard
|
| 240 |
+
// ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
describe('updateCardKitCard', () => {
|
| 243 |
+
it('把 card 包装成 card_json payload + sequence', async () => {
|
| 244 |
+
const { client, calls } = makeMockClient({
|
| 245 |
+
'card.update': { code: 0 },
|
| 246 |
+
})
|
| 247 |
+
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
|
| 248 |
+
await updateCardKitCard(client, 'ck_final', card, 100)
|
| 249 |
+
|
| 250 |
+
expect(calls.length).toBe(1)
|
| 251 |
+
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
|
| 252 |
+
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
|
| 253 |
+
expect(calls[0]!.args.data.sequence).toBe(100)
|
| 254 |
+
expect(calls[0]!.args.data.card.type).toBe('card_json')
|
| 255 |
+
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
it('非零 code 抛 CardKitApiError', async () => {
|
| 259 |
+
const { client } = makeMockClient({
|
| 260 |
+
'card.update': { code: -1, msg: 'bad card' },
|
| 261 |
+
})
|
| 262 |
+
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
|
| 263 |
+
})
|
| 264 |
+
})
|
| 265 |
+
|
| 266 |
+
// ---------------------------------------------------------------------------
|
| 267 |
+
// CardKitApiError
|
| 268 |
+
// ---------------------------------------------------------------------------
|
| 269 |
+
|
| 270 |
+
describe('CardKitApiError', () => {
|
| 271 |
+
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
|
| 272 |
+
const err = new CardKitApiError({
|
| 273 |
+
api: 'card.update',
|
| 274 |
+
code: 230020,
|
| 275 |
+
msg: 'rate limited',
|
| 276 |
+
context: 'seq=5',
|
| 277 |
+
})
|
| 278 |
+
expect(err.code).toBe(230020)
|
| 279 |
+
expect(err.msg).toBe('rate limited')
|
| 280 |
+
expect(err.name).toBe('CardKitApiError')
|
| 281 |
+
expect(isCardRateLimitError(err)).toBe(true)
|
| 282 |
+
})
|
| 283 |
+
|
| 284 |
+
it('消息包含 api 名和 context', () => {
|
| 285 |
+
const err = new CardKitApiError({
|
| 286 |
+
api: 'cardElement.content',
|
| 287 |
+
code: 230099,
|
| 288 |
+
msg: 'oops',
|
| 289 |
+
context: 'seq=3 len=100',
|
| 290 |
+
})
|
| 291 |
+
expect(err.message).toContain('cardElement.content')
|
| 292 |
+
expect(err.message).toContain('230099')
|
| 293 |
+
expect(err.message).toContain('seq=3 len=100')
|
| 294 |
+
})
|
| 295 |
+
})
|
adapters/feishu/__tests__/extract-payload.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'bun:test'
|
| 2 |
+
import { extractInboundPayload } from '../extract-payload.js'
|
| 3 |
+
|
| 4 |
+
describe('extractInboundPayload', () => {
|
| 5 |
+
it('pulls text out of a text message', () => {
|
| 6 |
+
const result = extractInboundPayload(
|
| 7 |
+
JSON.stringify({ text: 'hello world' }),
|
| 8 |
+
'text',
|
| 9 |
+
)
|
| 10 |
+
expect(result.text).toBe('hello world')
|
| 11 |
+
expect(result.pendingDownloads).toEqual([])
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
it('pulls text out of a post (rich text) message', () => {
|
| 15 |
+
const content = JSON.stringify({
|
| 16 |
+
zh_cn: {
|
| 17 |
+
content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]],
|
| 18 |
+
},
|
| 19 |
+
})
|
| 20 |
+
const result = extractInboundPayload(content, 'post')
|
| 21 |
+
expect(result.text).toBe('hi there')
|
| 22 |
+
expect(result.pendingDownloads).toEqual([])
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
it('identifies an image message as a pending image download', () => {
|
| 26 |
+
const content = JSON.stringify({ image_key: 'img_key_abc' })
|
| 27 |
+
const result = extractInboundPayload(content, 'image')
|
| 28 |
+
expect(result.text).toBe('')
|
| 29 |
+
expect(result.pendingDownloads).toEqual([
|
| 30 |
+
{ kind: 'image', fileKey: 'img_key_abc' },
|
| 31 |
+
])
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
it('identifies a file message as a pending file download with file_name', () => {
|
| 35 |
+
const content = JSON.stringify({
|
| 36 |
+
file_key: 'file_key_xyz',
|
| 37 |
+
file_name: 'spec.pdf',
|
| 38 |
+
})
|
| 39 |
+
const result = extractInboundPayload(content, 'file')
|
| 40 |
+
expect(result.pendingDownloads).toEqual([
|
| 41 |
+
{ kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' },
|
| 42 |
+
])
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
it('identifies file_archive the same way as file', () => {
|
| 46 |
+
const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' })
|
| 47 |
+
const result = extractInboundPayload(content, 'file_archive')
|
| 48 |
+
expect(result.pendingDownloads).toEqual([
|
| 49 |
+
{ kind: 'file', fileKey: 'fk1', fileName: 'x.zip' },
|
| 50 |
+
])
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
it('extracts img + file elements from a post message', () => {
|
| 54 |
+
const content = JSON.stringify({
|
| 55 |
+
zh_cn: {
|
| 56 |
+
content: [
|
| 57 |
+
[{ tag: 'text', text: 'look: ' }],
|
| 58 |
+
[{ tag: 'img', image_key: 'img_post_1' }],
|
| 59 |
+
[{ tag: 'text', text: ' and ' }],
|
| 60 |
+
[{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }],
|
| 61 |
+
],
|
| 62 |
+
},
|
| 63 |
+
})
|
| 64 |
+
const result = extractInboundPayload(content, 'post')
|
| 65 |
+
expect(result.text).toBe('look: and ')
|
| 66 |
+
expect(result.pendingDownloads).toEqual([
|
| 67 |
+
{ kind: 'image', fileKey: 'img_post_1' },
|
| 68 |
+
{ kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' },
|
| 69 |
+
])
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
it('returns empty on malformed JSON', () => {
|
| 73 |
+
const result = extractInboundPayload('not json', 'text')
|
| 74 |
+
expect(result.text).toBe('')
|
| 75 |
+
expect(result.pendingDownloads).toEqual([])
|
| 76 |
+
})
|
| 77 |
+
})
|
adapters/feishu/__tests__/feishu.test.ts
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 飞书 Adapter 翻译逻辑测试
|
| 3 |
+
*
|
| 4 |
+
* 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { describe, it, expect } from 'bun:test'
|
| 8 |
+
import { isOutsideWorkDir } from '../path-safety.js'
|
| 9 |
+
|
| 10 |
+
// ---------- helpers extracted from feishu/index.ts for testability ----------
|
| 11 |
+
|
| 12 |
+
function extractText(content: string, msgType: string): string | null {
|
| 13 |
+
try {
|
| 14 |
+
const parsed = JSON.parse(content)
|
| 15 |
+
if (msgType === 'text') {
|
| 16 |
+
return parsed.text ?? null
|
| 17 |
+
}
|
| 18 |
+
if (msgType === 'post') {
|
| 19 |
+
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
|
| 20 |
+
return zhContent
|
| 21 |
+
.flat()
|
| 22 |
+
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
|
| 23 |
+
.map((n: any) => n.text ?? n.content ?? '')
|
| 24 |
+
.join('')
|
| 25 |
+
.trim() || null
|
| 26 |
+
}
|
| 27 |
+
return null
|
| 28 |
+
} catch {
|
| 29 |
+
return null
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function isBotMentioned(
|
| 34 |
+
mentions: Array<{ id?: { open_id?: string } }> | undefined,
|
| 35 |
+
botOpenId: string,
|
| 36 |
+
): boolean {
|
| 37 |
+
if (!mentions || !botOpenId) return false
|
| 38 |
+
return mentions.some((m) => m.id?.open_id === botOpenId)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function stripMentions(text: string): string {
|
| 42 |
+
return text.replace(/@_user_\d+/g, '').trim()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
type RecentProject = {
|
| 46 |
+
projectPath: string
|
| 47 |
+
realPath: string
|
| 48 |
+
projectName: string
|
| 49 |
+
isGit: boolean
|
| 50 |
+
repoName: string | null
|
| 51 |
+
branch: string | null
|
| 52 |
+
modifiedAt: string
|
| 53 |
+
sessionCount: number
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function prettyPath(realPath: string, maxLen = 64): string {
|
| 57 |
+
const home = process.env.HOME
|
| 58 |
+
let p = realPath
|
| 59 |
+
if (home) {
|
| 60 |
+
if (p === home) return '~'
|
| 61 |
+
if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
|
| 62 |
+
}
|
| 63 |
+
if (p.length <= maxLen) return p
|
| 64 |
+
const tailLen = Math.floor(maxLen * 0.65)
|
| 65 |
+
const headLen = maxLen - tailLen - 1
|
| 66 |
+
return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
|
| 70 |
+
const items = projects.slice(0, 10)
|
| 71 |
+
const total = projects.length
|
| 72 |
+
const subtitleText =
|
| 73 |
+
total > items.length
|
| 74 |
+
? `共 ${total} 个最近项目,显示前 ${items.length}`
|
| 75 |
+
: `共 ${total} 个最近项目`
|
| 76 |
+
|
| 77 |
+
const rows = items.map((p, i) => {
|
| 78 |
+
const branch = p.branch ? ` · *${p.branch}*` : ''
|
| 79 |
+
return {
|
| 80 |
+
tag: 'column_set',
|
| 81 |
+
flex_mode: 'stretch',
|
| 82 |
+
horizontal_spacing: '8px',
|
| 83 |
+
margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
|
| 84 |
+
columns: [
|
| 85 |
+
{
|
| 86 |
+
tag: 'column',
|
| 87 |
+
width: 'weighted',
|
| 88 |
+
weight: 1,
|
| 89 |
+
vertical_align: 'center',
|
| 90 |
+
elements: [
|
| 91 |
+
{
|
| 92 |
+
tag: 'markdown',
|
| 93 |
+
content: `**${p.projectName}**${branch}`,
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
tag: 'markdown',
|
| 97 |
+
content: prettyPath(p.realPath, 56),
|
| 98 |
+
text_size: 'notation',
|
| 99 |
+
margin: '2px 0 0 0',
|
| 100 |
+
},
|
| 101 |
+
],
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
tag: 'column',
|
| 105 |
+
width: 'auto',
|
| 106 |
+
vertical_align: 'center',
|
| 107 |
+
elements: [
|
| 108 |
+
{
|
| 109 |
+
tag: 'button',
|
| 110 |
+
text: { tag: 'plain_text', content: '选择' },
|
| 111 |
+
type: i === 0 ? 'primary' : 'default',
|
| 112 |
+
size: 'small',
|
| 113 |
+
value: {
|
| 114 |
+
action: 'pick_project',
|
| 115 |
+
realPath: p.realPath,
|
| 116 |
+
projectName: p.projectName,
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
],
|
| 120 |
+
},
|
| 121 |
+
],
|
| 122 |
+
}
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
schema: '2.0',
|
| 127 |
+
config: {
|
| 128 |
+
wide_screen_mode: true,
|
| 129 |
+
update_multi: true,
|
| 130 |
+
},
|
| 131 |
+
header: {
|
| 132 |
+
title: { tag: 'plain_text', content: '📁 选择项目' },
|
| 133 |
+
subtitle: { tag: 'plain_text', content: subtitleText },
|
| 134 |
+
template: 'blue',
|
| 135 |
+
},
|
| 136 |
+
body: {
|
| 137 |
+
elements: [
|
| 138 |
+
...rows,
|
| 139 |
+
{ tag: 'hr', margin: '14px 0 0 0' },
|
| 140 |
+
{
|
| 141 |
+
tag: 'markdown',
|
| 142 |
+
content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
|
| 143 |
+
text_size: 'notation',
|
| 144 |
+
margin: '6px 0 0 0',
|
| 145 |
+
},
|
| 146 |
+
],
|
| 147 |
+
},
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// ---------- permission card helpers (mirrored from feishu/index.ts) ----------
|
| 152 |
+
|
| 153 |
+
type ToolCallSummary = {
|
| 154 |
+
icon: string
|
| 155 |
+
label: string
|
| 156 |
+
target?: string
|
| 157 |
+
filePath?: string
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
|
| 161 |
+
const rec: Record<string, unknown> =
|
| 162 |
+
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
| 163 |
+
const str = (key: string): string | undefined =>
|
| 164 |
+
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
|
| 165 |
+
|
| 166 |
+
switch (toolName) {
|
| 167 |
+
case 'Write': {
|
| 168 |
+
const fp = str('file_path')
|
| 169 |
+
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
|
| 170 |
+
}
|
| 171 |
+
case 'Edit':
|
| 172 |
+
case 'MultiEdit':
|
| 173 |
+
case 'NotebookEdit': {
|
| 174 |
+
const fp = str('file_path') ?? str('notebook_path')
|
| 175 |
+
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
|
| 176 |
+
}
|
| 177 |
+
case 'Read': {
|
| 178 |
+
const fp = str('file_path')
|
| 179 |
+
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
|
| 180 |
+
}
|
| 181 |
+
case 'Bash':
|
| 182 |
+
case 'BashOutput': {
|
| 183 |
+
return { icon: '🖥️', label: '执行命令', target: str('command') }
|
| 184 |
+
}
|
| 185 |
+
case 'Grep': {
|
| 186 |
+
const pattern = str('pattern')
|
| 187 |
+
return {
|
| 188 |
+
icon: '🔍',
|
| 189 |
+
label: '搜索内容',
|
| 190 |
+
target: pattern ? `pattern: ${pattern}` : undefined,
|
| 191 |
+
filePath: str('path'),
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
case 'Glob': {
|
| 195 |
+
const pattern = str('pattern')
|
| 196 |
+
return {
|
| 197 |
+
icon: '📁',
|
| 198 |
+
label: '查找文件',
|
| 199 |
+
target: pattern ? `pattern: ${pattern}` : undefined,
|
| 200 |
+
filePath: str('path'),
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
case 'WebFetch':
|
| 204 |
+
return { icon: '🌐', label: '访问网页', target: str('url') }
|
| 205 |
+
case 'WebSearch':
|
| 206 |
+
return { icon: '🌐', label: '搜索网页', target: str('query') }
|
| 207 |
+
default:
|
| 208 |
+
return { icon: '🔧', label: toolName }
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function truncateTarget(s: string, maxLen = 160): string {
|
| 213 |
+
if (s.length <= maxLen) return s
|
| 214 |
+
return s.slice(0, maxLen - 1) + '…'
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function buildPermissionCard(
|
| 218 |
+
toolName: string,
|
| 219 |
+
input: unknown,
|
| 220 |
+
requestId: string,
|
| 221 |
+
workDir?: string,
|
| 222 |
+
): Record<string, unknown> {
|
| 223 |
+
const summary = summarizeToolCall(toolName, input)
|
| 224 |
+
const crossDir = Boolean(
|
| 225 |
+
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
const elements: Record<string, unknown>[] = [
|
| 229 |
+
{
|
| 230 |
+
tag: 'markdown',
|
| 231 |
+
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
|
| 232 |
+
},
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
if (summary.target) {
|
| 236 |
+
const shown = summary.filePath
|
| 237 |
+
? prettyPath(summary.target, 80)
|
| 238 |
+
: truncateTarget(summary.target, 160)
|
| 239 |
+
elements.push({
|
| 240 |
+
tag: 'markdown',
|
| 241 |
+
content: '```\n' + shown + '\n```',
|
| 242 |
+
margin: '4px 0 0 0',
|
| 243 |
+
})
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
if (crossDir) {
|
| 247 |
+
elements.push({
|
| 248 |
+
tag: 'markdown',
|
| 249 |
+
content: '⚠️ **该操作位于当前项目目录之外**',
|
| 250 |
+
margin: '8px 0 0 0',
|
| 251 |
+
text_size: 'notation',
|
| 252 |
+
})
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
|
| 256 |
+
|
| 257 |
+
elements.push({
|
| 258 |
+
tag: 'column_set',
|
| 259 |
+
flex_mode: 'stretch',
|
| 260 |
+
horizontal_spacing: '8px',
|
| 261 |
+
margin: '8px 0 0 0',
|
| 262 |
+
columns: [
|
| 263 |
+
{
|
| 264 |
+
tag: 'column',
|
| 265 |
+
width: 'weighted',
|
| 266 |
+
weight: 1,
|
| 267 |
+
vertical_align: 'center',
|
| 268 |
+
elements: [
|
| 269 |
+
{
|
| 270 |
+
tag: 'button',
|
| 271 |
+
text: { tag: 'plain_text', content: '✅ 允许' },
|
| 272 |
+
type: 'primary',
|
| 273 |
+
size: 'medium',
|
| 274 |
+
value: { action: 'permit', requestId, allowed: true },
|
| 275 |
+
},
|
| 276 |
+
],
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
tag: 'column',
|
| 280 |
+
width: 'weighted',
|
| 281 |
+
weight: 1,
|
| 282 |
+
vertical_align: 'center',
|
| 283 |
+
elements: [
|
| 284 |
+
{
|
| 285 |
+
tag: 'button',
|
| 286 |
+
text: { tag: 'plain_text', content: '♾️ 永久允许' },
|
| 287 |
+
type: 'default',
|
| 288 |
+
size: 'medium',
|
| 289 |
+
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
|
| 290 |
+
},
|
| 291 |
+
],
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
tag: 'column',
|
| 295 |
+
width: 'weighted',
|
| 296 |
+
weight: 1,
|
| 297 |
+
vertical_align: 'center',
|
| 298 |
+
elements: [
|
| 299 |
+
{
|
| 300 |
+
tag: 'button',
|
| 301 |
+
text: { tag: 'plain_text', content: '❌ 拒绝' },
|
| 302 |
+
type: 'danger',
|
| 303 |
+
size: 'medium',
|
| 304 |
+
value: { action: 'permit', requestId, allowed: false },
|
| 305 |
+
},
|
| 306 |
+
],
|
| 307 |
+
},
|
| 308 |
+
],
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
schema: '2.0',
|
| 313 |
+
config: {
|
| 314 |
+
wide_screen_mode: false,
|
| 315 |
+
update_multi: true,
|
| 316 |
+
},
|
| 317 |
+
header: {
|
| 318 |
+
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
|
| 319 |
+
subtitle: {
|
| 320 |
+
tag: 'plain_text',
|
| 321 |
+
content: crossDir ? '⚠️ 跨目录操作' : toolName,
|
| 322 |
+
},
|
| 323 |
+
template: crossDir ? 'red' : 'orange',
|
| 324 |
+
padding: '12px 12px 12px 12px',
|
| 325 |
+
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
|
| 326 |
+
},
|
| 327 |
+
body: { elements },
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// ---------- tests ----------
|
| 332 |
+
|
| 333 |
+
describe('Feishu: event parsing', () => {
|
| 334 |
+
describe('extractText', () => {
|
| 335 |
+
it('extracts text from text message', () => {
|
| 336 |
+
const content = JSON.stringify({ text: 'hello world' })
|
| 337 |
+
expect(extractText(content, 'text')).toBe('hello world')
|
| 338 |
+
})
|
| 339 |
+
|
| 340 |
+
it('extracts text from post message (zh_cn)', () => {
|
| 341 |
+
const content = JSON.stringify({
|
| 342 |
+
zh_cn: {
|
| 343 |
+
content: [[
|
| 344 |
+
{ tag: 'text', text: 'Hello ' },
|
| 345 |
+
{ tag: 'text', text: 'World' },
|
| 346 |
+
]],
|
| 347 |
+
},
|
| 348 |
+
})
|
| 349 |
+
expect(extractText(content, 'post')).toBe('Hello World')
|
| 350 |
+
})
|
| 351 |
+
|
| 352 |
+
it('extracts text from post message with md tag', () => {
|
| 353 |
+
const content = JSON.stringify({
|
| 354 |
+
zh_cn: {
|
| 355 |
+
content: [[{ tag: 'md', text: '**bold** text' }]],
|
| 356 |
+
},
|
| 357 |
+
})
|
| 358 |
+
expect(extractText(content, 'post')).toBe('**bold** text')
|
| 359 |
+
})
|
| 360 |
+
|
| 361 |
+
it('returns null for unsupported message types', () => {
|
| 362 |
+
expect(extractText('{}', 'image')).toBeNull()
|
| 363 |
+
expect(extractText('{}', 'audio')).toBeNull()
|
| 364 |
+
})
|
| 365 |
+
|
| 366 |
+
it('returns null for malformed content', () => {
|
| 367 |
+
expect(extractText('not-json', 'text')).toBeNull()
|
| 368 |
+
})
|
| 369 |
+
|
| 370 |
+
it('returns null for empty text', () => {
|
| 371 |
+
const content = JSON.stringify({ text: '' })
|
| 372 |
+
// empty string is falsy, so ?? null returns ''
|
| 373 |
+
expect(extractText(content, 'text')).toBe('')
|
| 374 |
+
})
|
| 375 |
+
})
|
| 376 |
+
|
| 377 |
+
describe('isBotMentioned', () => {
|
| 378 |
+
const botId = 'ou_bot_123'
|
| 379 |
+
|
| 380 |
+
it('returns true when bot is mentioned', () => {
|
| 381 |
+
const mentions = [
|
| 382 |
+
{ id: { open_id: 'ou_user_1' } },
|
| 383 |
+
{ id: { open_id: 'ou_bot_123' } },
|
| 384 |
+
]
|
| 385 |
+
expect(isBotMentioned(mentions, botId)).toBe(true)
|
| 386 |
+
})
|
| 387 |
+
|
| 388 |
+
it('returns false when bot is not mentioned', () => {
|
| 389 |
+
const mentions = [
|
| 390 |
+
{ id: { open_id: 'ou_user_1' } },
|
| 391 |
+
{ id: { open_id: 'ou_user_2' } },
|
| 392 |
+
]
|
| 393 |
+
expect(isBotMentioned(mentions, botId)).toBe(false)
|
| 394 |
+
})
|
| 395 |
+
|
| 396 |
+
it('returns false for undefined mentions', () => {
|
| 397 |
+
expect(isBotMentioned(undefined, botId)).toBe(false)
|
| 398 |
+
})
|
| 399 |
+
|
| 400 |
+
it('returns false for empty mentions', () => {
|
| 401 |
+
expect(isBotMentioned([], botId)).toBe(false)
|
| 402 |
+
})
|
| 403 |
+
})
|
| 404 |
+
|
| 405 |
+
describe('stripMentions', () => {
|
| 406 |
+
it('removes @_user_N patterns', () => {
|
| 407 |
+
expect(stripMentions('@_user_1 hello world')).toBe('hello world')
|
| 408 |
+
})
|
| 409 |
+
|
| 410 |
+
it('removes multiple mentions', () => {
|
| 411 |
+
expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
|
| 412 |
+
})
|
| 413 |
+
|
| 414 |
+
it('leaves text without mentions unchanged', () => {
|
| 415 |
+
expect(stripMentions('hello world')).toBe('hello world')
|
| 416 |
+
})
|
| 417 |
+
|
| 418 |
+
it('trims whitespace', () => {
|
| 419 |
+
expect(stripMentions(' @_user_1 hello ')).toBe('hello')
|
| 420 |
+
})
|
| 421 |
+
})
|
| 422 |
+
})
|
| 423 |
+
|
| 424 |
+
describe('Feishu: permission card', () => {
|
| 425 |
+
// Helpers to reach into Schema 2.0 body.elements
|
| 426 |
+
function getBodyElements(card: Record<string, unknown>): any[] {
|
| 427 |
+
return ((card.body as any).elements ?? []) as any[]
|
| 428 |
+
}
|
| 429 |
+
function getActionRow(card: Record<string, unknown>): any {
|
| 430 |
+
return getBodyElements(card).find((el) => el.tag === 'column_set')
|
| 431 |
+
}
|
| 432 |
+
function getButtons(card: Record<string, unknown>): any[] {
|
| 433 |
+
return getActionRow(card).columns.map(
|
| 434 |
+
(c: any) => c.elements.find((e: any) => e.tag === 'button'),
|
| 435 |
+
)
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// ----- Schema 2.0 regression -----
|
| 439 |
+
|
| 440 |
+
it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
|
| 441 |
+
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
| 442 |
+
expect(card.schema).toBe('2.0')
|
| 443 |
+
expect(card.elements).toBeUndefined() // old bug had top-level elements
|
| 444 |
+
expect((card.body as any).elements).toBeDefined()
|
| 445 |
+
expect((card.config as any).update_multi).toBe(true)
|
| 446 |
+
expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
|
| 447 |
+
})
|
| 448 |
+
|
| 449 |
+
it('header has title, subtitle, template, icon', () => {
|
| 450 |
+
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
| 451 |
+
const header = card.header as any
|
| 452 |
+
expect(header.title.content).toContain('权限确认')
|
| 453 |
+
expect(header.subtitle.content).toBe('Bash')
|
| 454 |
+
expect(header.template).toBe('orange')
|
| 455 |
+
expect(header.icon.tag).toBe('standard_icon')
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
// ----- Three buttons -----
|
| 459 |
+
|
| 460 |
+
it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
|
| 461 |
+
const card = buildPermissionCard('Read', {}, 'xyz')
|
| 462 |
+
const [allow, always, deny] = getButtons(card)
|
| 463 |
+
expect(allow.text.content).toContain('允许')
|
| 464 |
+
expect(allow.type).toBe('primary')
|
| 465 |
+
expect(always.text.content).toContain('永久允许')
|
| 466 |
+
expect(always.type).toBe('default')
|
| 467 |
+
expect(deny.text.content).toContain('拒绝')
|
| 468 |
+
expect(deny.type).toBe('danger')
|
| 469 |
+
})
|
| 470 |
+
|
| 471 |
+
it('允许 button carries allowed=true and no rule', () => {
|
| 472 |
+
const card = buildPermissionCard('Read', {}, 'req-1')
|
| 473 |
+
const [allow] = getButtons(card)
|
| 474 |
+
expect(allow.value).toEqual({
|
| 475 |
+
action: 'permit',
|
| 476 |
+
requestId: 'req-1',
|
| 477 |
+
allowed: true,
|
| 478 |
+
})
|
| 479 |
+
expect(allow.value.rule).toBeUndefined()
|
| 480 |
+
})
|
| 481 |
+
|
| 482 |
+
it('永久允许 button carries allowed=true + rule=always', () => {
|
| 483 |
+
const card = buildPermissionCard('Read', {}, 'req-2')
|
| 484 |
+
const always = getButtons(card)[1]
|
| 485 |
+
expect(always.value).toEqual({
|
| 486 |
+
action: 'permit',
|
| 487 |
+
requestId: 'req-2',
|
| 488 |
+
allowed: true,
|
| 489 |
+
rule: 'always',
|
| 490 |
+
})
|
| 491 |
+
})
|
| 492 |
+
|
| 493 |
+
it('拒绝 button carries allowed=false and no rule', () => {
|
| 494 |
+
const card = buildPermissionCard('Read', {}, 'req-3')
|
| 495 |
+
const deny = getButtons(card)[2]
|
| 496 |
+
expect(deny.value).toEqual({
|
| 497 |
+
action: 'permit',
|
| 498 |
+
requestId: 'req-3',
|
| 499 |
+
allowed: false,
|
| 500 |
+
})
|
| 501 |
+
})
|
| 502 |
+
|
| 503 |
+
// ----- Tool summary rendering -----
|
| 504 |
+
|
| 505 |
+
it('renders Write with ✏️ 写入文件 header and file path target', () => {
|
| 506 |
+
const card = buildPermissionCard(
|
| 507 |
+
'Write',
|
| 508 |
+
{ file_path: '/tmp/output.txt', content: 'hi' },
|
| 509 |
+
'req',
|
| 510 |
+
)
|
| 511 |
+
const elements = getBodyElements(card)
|
| 512 |
+
expect(elements[0].content).toContain('✏️')
|
| 513 |
+
expect(elements[0].content).toContain('写入文件')
|
| 514 |
+
expect(elements[0].content).toContain('`Write`')
|
| 515 |
+
// Target rendered as fenced code block
|
| 516 |
+
expect(elements[1].content).toContain('/tmp/output.txt')
|
| 517 |
+
expect(elements[1].content.startsWith('```')).toBe(true)
|
| 518 |
+
})
|
| 519 |
+
|
| 520 |
+
it('renders Edit with ✏️ 修改文件', () => {
|
| 521 |
+
const card = buildPermissionCard(
|
| 522 |
+
'Edit',
|
| 523 |
+
{ file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
|
| 524 |
+
'req',
|
| 525 |
+
)
|
| 526 |
+
expect(getBodyElements(card)[0].content).toContain('修改文件')
|
| 527 |
+
})
|
| 528 |
+
|
| 529 |
+
it('renders Bash with 🖥️ 执行命令 and command target', () => {
|
| 530 |
+
const card = buildPermissionCard(
|
| 531 |
+
'Bash',
|
| 532 |
+
{ command: 'rm -rf /tmp/x' },
|
| 533 |
+
'req',
|
| 534 |
+
)
|
| 535 |
+
const elements = getBodyElements(card)
|
| 536 |
+
expect(elements[0].content).toContain('🖥️')
|
| 537 |
+
expect(elements[0].content).toContain('执行命令')
|
| 538 |
+
expect(elements[1].content).toContain('rm -rf /tmp/x')
|
| 539 |
+
})
|
| 540 |
+
|
| 541 |
+
it('truncates very long Bash commands to 160 chars', () => {
|
| 542 |
+
const longCmd = 'echo ' + 'x'.repeat(500)
|
| 543 |
+
const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
|
| 544 |
+
const targetEl = getBodyElements(card)[1]
|
| 545 |
+
expect(targetEl.content).toContain('…')
|
| 546 |
+
// Fenced code wraps ~10 extra chars
|
| 547 |
+
expect(targetEl.content.length).toBeLessThanOrEqual(180)
|
| 548 |
+
})
|
| 549 |
+
|
| 550 |
+
it('renders Grep with 🔍 搜索内容 and pattern target', () => {
|
| 551 |
+
const card = buildPermissionCard(
|
| 552 |
+
'Grep',
|
| 553 |
+
{ pattern: 'TODO', path: '/src' },
|
| 554 |
+
'req',
|
| 555 |
+
)
|
| 556 |
+
const elements = getBodyElements(card)
|
| 557 |
+
expect(elements[0].content).toContain('🔍')
|
| 558 |
+
expect(elements[1].content).toContain('TODO')
|
| 559 |
+
})
|
| 560 |
+
|
| 561 |
+
it('renders WebFetch with 🌐 访问网页 and url target', () => {
|
| 562 |
+
const card = buildPermissionCard(
|
| 563 |
+
'WebFetch',
|
| 564 |
+
{ url: 'https://example.com/api' },
|
| 565 |
+
'req',
|
| 566 |
+
)
|
| 567 |
+
const elements = getBodyElements(card)
|
| 568 |
+
expect(elements[0].content).toContain('🌐')
|
| 569 |
+
expect(elements[0].content).toContain('访问网页')
|
| 570 |
+
expect(elements[1].content).toContain('https://example.com/api')
|
| 571 |
+
})
|
| 572 |
+
|
| 573 |
+
it('falls back to 🔧 <toolName> for unknown tools', () => {
|
| 574 |
+
const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
|
| 575 |
+
expect(getBodyElements(card)[0].content).toContain('🔧')
|
| 576 |
+
expect(getBodyElements(card)[0].content).toContain('CustomTool')
|
| 577 |
+
})
|
| 578 |
+
|
| 579 |
+
it('has no target line when input is empty', () => {
|
| 580 |
+
const card = buildPermissionCard('Bash', {}, 'req')
|
| 581 |
+
const elements = getBodyElements(card)
|
| 582 |
+
// elements: [header_md, hr, action_column_set]
|
| 583 |
+
expect(elements[1].tag).toBe('hr')
|
| 584 |
+
})
|
| 585 |
+
|
| 586 |
+
// ----- Cross-directory detection -----
|
| 587 |
+
|
| 588 |
+
it('does NOT show cross-dir warning when file is inside workDir', () => {
|
| 589 |
+
const card = buildPermissionCard(
|
| 590 |
+
'Write',
|
| 591 |
+
{ file_path: '/Users/me/proj/src/a.ts' },
|
| 592 |
+
'req',
|
| 593 |
+
'/Users/me/proj',
|
| 594 |
+
)
|
| 595 |
+
const elements = getBodyElements(card)
|
| 596 |
+
const hasWarn = elements.some(
|
| 597 |
+
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
| 598 |
+
)
|
| 599 |
+
expect(hasWarn).toBe(false)
|
| 600 |
+
expect((card.header as any).template).toBe('orange')
|
| 601 |
+
expect((card.header as any).subtitle.content).toBe('Write')
|
| 602 |
+
})
|
| 603 |
+
|
| 604 |
+
it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
|
| 605 |
+
const card = buildPermissionCard(
|
| 606 |
+
'Write',
|
| 607 |
+
{ file_path: '/tmp/evil.sh' },
|
| 608 |
+
'req',
|
| 609 |
+
'/Users/me/proj',
|
| 610 |
+
)
|
| 611 |
+
const elements = getBodyElements(card)
|
| 612 |
+
const warn = elements.find(
|
| 613 |
+
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
| 614 |
+
)
|
| 615 |
+
expect(warn).toBeDefined()
|
| 616 |
+
expect((card.header as any).template).toBe('red')
|
| 617 |
+
expect((card.header as any).subtitle.content).toContain('跨目录')
|
| 618 |
+
})
|
| 619 |
+
|
| 620 |
+
it('does NOT check cross-dir for Bash (no filePath)', () => {
|
| 621 |
+
const card = buildPermissionCard(
|
| 622 |
+
'Bash',
|
| 623 |
+
{ command: 'rm -rf /tmp/x' },
|
| 624 |
+
'req',
|
| 625 |
+
'/Users/me/proj',
|
| 626 |
+
)
|
| 627 |
+
expect((card.header as any).template).toBe('orange')
|
| 628 |
+
})
|
| 629 |
+
|
| 630 |
+
it('does not warn when workDir is not provided', () => {
|
| 631 |
+
const card = buildPermissionCard(
|
| 632 |
+
'Write',
|
| 633 |
+
{ file_path: '/tmp/x.ts' },
|
| 634 |
+
'req',
|
| 635 |
+
// workDir omitted
|
| 636 |
+
)
|
| 637 |
+
const elements = getBodyElements(card)
|
| 638 |
+
const hasWarn = elements.some(
|
| 639 |
+
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
| 640 |
+
)
|
| 641 |
+
expect(hasWarn).toBe(false)
|
| 642 |
+
})
|
| 643 |
+
})
|
| 644 |
+
|
| 645 |
+
describe('Feishu: isOutsideWorkDir', () => {
|
| 646 |
+
it('returns false for file inside workDir', () => {
|
| 647 |
+
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
|
| 648 |
+
})
|
| 649 |
+
|
| 650 |
+
it('returns false for file directly in workDir', () => {
|
| 651 |
+
expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
|
| 652 |
+
})
|
| 653 |
+
|
| 654 |
+
it('returns true for file in a sibling directory', () => {
|
| 655 |
+
expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
|
| 656 |
+
})
|
| 657 |
+
|
| 658 |
+
it('returns true for /tmp file', () => {
|
| 659 |
+
expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
|
| 660 |
+
})
|
| 661 |
+
|
| 662 |
+
it('handles workDir with trailing slash', () => {
|
| 663 |
+
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
|
| 664 |
+
})
|
| 665 |
+
|
| 666 |
+
it('resolves relative paths against workDir', () => {
|
| 667 |
+
expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
|
| 668 |
+
expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
|
| 669 |
+
})
|
| 670 |
+
|
| 671 |
+
it('does not match prefix collisions (proj vs proj2)', () => {
|
| 672 |
+
// /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
|
| 673 |
+
// but is NOT inside /Users/me/proj
|
| 674 |
+
expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
|
| 675 |
+
})
|
| 676 |
+
})
|
| 677 |
+
|
| 678 |
+
describe('Feishu: project picker card', () => {
|
| 679 |
+
const sampleProjects: RecentProject[] = [
|
| 680 |
+
{
|
| 681 |
+
projectPath: '/Users/dev/claude-code-haha',
|
| 682 |
+
realPath: '/Users/dev/claude-code-haha',
|
| 683 |
+
projectName: 'claude-code-haha',
|
| 684 |
+
isGit: true,
|
| 685 |
+
repoName: 'claude-code-haha',
|
| 686 |
+
branch: 'main',
|
| 687 |
+
modifiedAt: '2026-04-11T00:00:00Z',
|
| 688 |
+
sessionCount: 3,
|
| 689 |
+
},
|
| 690 |
+
{
|
| 691 |
+
projectPath: '/Users/dev/desktop',
|
| 692 |
+
realPath: '/Users/dev/desktop',
|
| 693 |
+
projectName: 'desktop',
|
| 694 |
+
isGit: false,
|
| 695 |
+
repoName: null,
|
| 696 |
+
branch: null,
|
| 697 |
+
modifiedAt: '2026-04-10T00:00:00Z',
|
| 698 |
+
sessionCount: 1,
|
| 699 |
+
},
|
| 700 |
+
]
|
| 701 |
+
|
| 702 |
+
function getBodyElements(card: Record<string, unknown>): any[] {
|
| 703 |
+
return ((card.body as any).elements ?? []) as any[]
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function getRows(card: Record<string, unknown>): any[] {
|
| 707 |
+
return getBodyElements(card).filter((el) => el.tag === 'column_set')
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
function getRowButton(row: any): any {
|
| 711 |
+
const buttonCol = row.columns.find((c: any) =>
|
| 712 |
+
c.elements.some((e: any) => e.tag === 'button'),
|
| 713 |
+
)
|
| 714 |
+
return buttonCol.elements.find((e: any) => e.tag === 'button')
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
function getRowInfoElements(row: any): any[] {
|
| 718 |
+
const infoCol = row.columns.find((c: any) =>
|
| 719 |
+
c.elements.every((e: any) => e.tag === 'markdown'),
|
| 720 |
+
)
|
| 721 |
+
return infoCol.elements
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
it('uses Schema 2.0 with body.elements wrapper', () => {
|
| 725 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 726 |
+
expect(card.schema).toBe('2.0')
|
| 727 |
+
expect((card.config as any).update_multi).toBe(true)
|
| 728 |
+
expect((card.body as any).elements).toBeDefined()
|
| 729 |
+
})
|
| 730 |
+
|
| 731 |
+
it('header has title and project-count subtitle', () => {
|
| 732 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 733 |
+
expect((card.header as any).title.content).toContain('选择项目')
|
| 734 |
+
expect((card.header as any).subtitle.content).toContain('2')
|
| 735 |
+
expect((card.header as any).subtitle.content).toContain('最近项目')
|
| 736 |
+
})
|
| 737 |
+
|
| 738 |
+
it('subtitle notes truncation when more than 10 projects exist', () => {
|
| 739 |
+
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
| 740 |
+
...sampleProjects[0]!,
|
| 741 |
+
projectName: `proj-${i}`,
|
| 742 |
+
realPath: `/p/${i}`,
|
| 743 |
+
}))
|
| 744 |
+
const card = buildProjectPickerCard(many)
|
| 745 |
+
const subtitle = (card.header as any).subtitle.content
|
| 746 |
+
expect(subtitle).toContain('15')
|
| 747 |
+
expect(subtitle).toContain('显示前 10')
|
| 748 |
+
})
|
| 749 |
+
|
| 750 |
+
it('body contains one column_set row per project', () => {
|
| 751 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 752 |
+
expect(getRows(card).length).toBe(2)
|
| 753 |
+
})
|
| 754 |
+
|
| 755 |
+
it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
|
| 756 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 757 |
+
for (const row of getRows(card)) {
|
| 758 |
+
expect(row.columns.length).toBe(2)
|
| 759 |
+
expect(row.columns[0].width).toBe('weighted')
|
| 760 |
+
expect(row.columns[0].vertical_align).toBe('center')
|
| 761 |
+
expect(row.columns[1].width).toBe('auto')
|
| 762 |
+
expect(row.columns[1].vertical_align).toBe('center')
|
| 763 |
+
}
|
| 764 |
+
})
|
| 765 |
+
|
| 766 |
+
it('info column has title markdown + notation path markdown', () => {
|
| 767 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 768 |
+
const row1 = getRows(card)[0]
|
| 769 |
+
const info = getRowInfoElements(row1)
|
| 770 |
+
|
| 771 |
+
expect(info.length).toBe(2)
|
| 772 |
+
// Title markdown
|
| 773 |
+
expect(info[0].tag).toBe('markdown')
|
| 774 |
+
expect(info[0].content).toContain('**claude-code-haha**')
|
| 775 |
+
expect(info[0].content).toContain('*main*')
|
| 776 |
+
// Path markdown (notation = small grey)
|
| 777 |
+
expect(info[1].tag).toBe('markdown')
|
| 778 |
+
expect(info[1].text_size).toBe('notation')
|
| 779 |
+
expect(info[1].content).toContain('claude-code-haha')
|
| 780 |
+
})
|
| 781 |
+
|
| 782 |
+
it('row without branch has no separator dot in title', () => {
|
| 783 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 784 |
+
const row2 = getRows(card)[1]
|
| 785 |
+
const title = getRowInfoElements(row2)[0].content
|
| 786 |
+
expect(title).toContain('**desktop**')
|
| 787 |
+
expect(title).not.toContain('·')
|
| 788 |
+
})
|
| 789 |
+
|
| 790 |
+
it('row button says 选择 with small size and carries per-project value', () => {
|
| 791 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 792 |
+
const rows = getRows(card)
|
| 793 |
+
|
| 794 |
+
const btn1 = getRowButton(rows[0])
|
| 795 |
+
expect(btn1.text.content).toBe('选择')
|
| 796 |
+
expect(btn1.size).toBe('small')
|
| 797 |
+
expect(btn1.value.action).toBe('pick_project')
|
| 798 |
+
expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
|
| 799 |
+
expect(btn1.value.projectName).toBe('claude-code-haha')
|
| 800 |
+
|
| 801 |
+
const btn2 = getRowButton(rows[1])
|
| 802 |
+
expect(btn2.value.realPath).toBe('/Users/dev/desktop')
|
| 803 |
+
})
|
| 804 |
+
|
| 805 |
+
it('first row button is primary, rest are default', () => {
|
| 806 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 807 |
+
const rows = getRows(card)
|
| 808 |
+
expect(getRowButton(rows[0]).type).toBe('primary')
|
| 809 |
+
expect(getRowButton(rows[1]).type).toBe('default')
|
| 810 |
+
})
|
| 811 |
+
|
| 812 |
+
it('body tail has hr and notation footer hint', () => {
|
| 813 |
+
const card = buildProjectPickerCard(sampleProjects)
|
| 814 |
+
const elements = getBodyElements(card)
|
| 815 |
+
const hrIdx = elements.findIndex((el) => el.tag === 'hr')
|
| 816 |
+
expect(hrIdx).toBeGreaterThan(0)
|
| 817 |
+
expect(elements[hrIdx + 1].tag).toBe('markdown')
|
| 818 |
+
expect(elements[hrIdx + 1].text_size).toBe('notation')
|
| 819 |
+
})
|
| 820 |
+
|
| 821 |
+
it('caps to first 10 projects', () => {
|
| 822 |
+
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
| 823 |
+
...sampleProjects[0]!,
|
| 824 |
+
projectName: `proj-${i}`,
|
| 825 |
+
realPath: `/p/${i}`,
|
| 826 |
+
}))
|
| 827 |
+
const card = buildProjectPickerCard(many)
|
| 828 |
+
const rows = getRows(card)
|
| 829 |
+
expect(rows.length).toBe(10)
|
| 830 |
+
expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
|
| 831 |
+
})
|
| 832 |
+
|
| 833 |
+
it('uses ~ shortcut when path is under $HOME', () => {
|
| 834 |
+
const home = process.env.HOME
|
| 835 |
+
if (!home) return
|
| 836 |
+
const project: RecentProject = {
|
| 837 |
+
...sampleProjects[0]!,
|
| 838 |
+
realPath: `${home}/some/sub/dir`,
|
| 839 |
+
projectName: 'sub-dir',
|
| 840 |
+
}
|
| 841 |
+
const card = buildProjectPickerCard([project])
|
| 842 |
+
const pathEl = getRowInfoElements(getRows(card)[0])[1]
|
| 843 |
+
expect(pathEl.content).toBe('~/some/sub/dir')
|
| 844 |
+
})
|
| 845 |
+
|
| 846 |
+
it('middle-truncates very long paths with ellipsis', () => {
|
| 847 |
+
const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
|
| 848 |
+
const project: RecentProject = {
|
| 849 |
+
...sampleProjects[0]!,
|
| 850 |
+
realPath: veryLong,
|
| 851 |
+
projectName: 'project',
|
| 852 |
+
}
|
| 853 |
+
const card = buildProjectPickerCard([project])
|
| 854 |
+
const content = getRowInfoElements(getRows(card)[0])[1].content
|
| 855 |
+
expect(content).toContain('…')
|
| 856 |
+
expect(content.length).toBeLessThanOrEqual(56)
|
| 857 |
+
expect(content.endsWith('project')).toBe(true)
|
| 858 |
+
})
|
| 859 |
+
})
|
| 860 |
+
|
| 861 |
+
describe('Feishu: card.action.trigger parsing', () => {
|
| 862 |
+
it('parses permit action from event', () => {
|
| 863 |
+
const event = {
|
| 864 |
+
operator: { open_id: 'ou_user_1' },
|
| 865 |
+
action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
|
| 866 |
+
context: { open_chat_id: 'oc_chat_123' },
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
expect(event.action.value.action).toBe('permit')
|
| 870 |
+
expect(event.action.value.requestId).toBe('abcde')
|
| 871 |
+
expect(event.action.value.allowed).toBe(true)
|
| 872 |
+
expect(event.context.open_chat_id).toBe('oc_chat_123')
|
| 873 |
+
})
|
| 874 |
+
|
| 875 |
+
it('parses pick_project action from event', () => {
|
| 876 |
+
const event = {
|
| 877 |
+
operator: { open_id: 'ou_user_1' },
|
| 878 |
+
action: {
|
| 879 |
+
value: {
|
| 880 |
+
action: 'pick_project',
|
| 881 |
+
realPath: '/Users/dev/claude-code-haha',
|
| 882 |
+
projectName: 'claude-code-haha',
|
| 883 |
+
},
|
| 884 |
+
},
|
| 885 |
+
context: { open_chat_id: 'oc_chat_123' },
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
expect(event.action.value.action).toBe('pick_project')
|
| 889 |
+
expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
|
| 890 |
+
expect(event.action.value.projectName).toBe('claude-code-haha')
|
| 891 |
+
})
|
| 892 |
+
|
| 893 |
+
it('ignores non-handled actions', () => {
|
| 894 |
+
const event = {
|
| 895 |
+
action: { value: { action: 'other_action' } },
|
| 896 |
+
}
|
| 897 |
+
expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
|
| 898 |
+
})
|
| 899 |
+
})
|
adapters/feishu/__tests__/flush-controller.test.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* FlushController 单元测试
|
| 3 |
+
*
|
| 4 |
+
* 覆盖:
|
| 5 |
+
* - 基础: cardMessageReady gate, complete() 锁死
|
| 6 |
+
* - 节流窗口: 立即 flush / 延迟 flush
|
| 7 |
+
* - Mutex: 进行中的 flush 重复调用标记 needsReflush
|
| 8 |
+
* - Conflict reflush: API 结束后自动补一次
|
| 9 |
+
* - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush
|
| 10 |
+
* - waitForFlush: 等当前 flush 结束
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { describe, it, expect } from 'bun:test'
|
| 14 |
+
import { FlushController, THROTTLE } from '../flush-controller.js'
|
| 15 |
+
|
| 16 |
+
// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve
|
| 17 |
+
function makeControllableFlush() {
|
| 18 |
+
const calls: string[] = []
|
| 19 |
+
let resolveCurrent: (() => void) | null = null
|
| 20 |
+
let latch: Promise<void> | null = null
|
| 21 |
+
|
| 22 |
+
const doFlush = async () => {
|
| 23 |
+
calls.push('flush-start')
|
| 24 |
+
if (latch) {
|
| 25 |
+
await latch
|
| 26 |
+
latch = null
|
| 27 |
+
}
|
| 28 |
+
calls.push('flush-end')
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const blockNext = () => {
|
| 32 |
+
latch = new Promise<void>((resolve) => {
|
| 33 |
+
resolveCurrent = resolve
|
| 34 |
+
})
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const unblock = () => {
|
| 38 |
+
if (resolveCurrent) {
|
| 39 |
+
const r = resolveCurrent
|
| 40 |
+
resolveCurrent = null
|
| 41 |
+
r()
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return { doFlush, calls, blockNext, unblock }
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function sleep(ms: number): Promise<void> {
|
| 49 |
+
await new Promise((r) => setTimeout(r, ms))
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
// Basic gating
|
| 54 |
+
// ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
describe('FlushController: cardMessageReady gate', () => {
|
| 57 |
+
it('在 cardMessageReady=false 时不 flush', async () => {
|
| 58 |
+
let count = 0
|
| 59 |
+
const fc = new FlushController(async () => {
|
| 60 |
+
count += 1
|
| 61 |
+
})
|
| 62 |
+
await fc.flush()
|
| 63 |
+
await fc.throttledUpdate(50)
|
| 64 |
+
expect(count).toBe(0)
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
it('setCardMessageReady(true) 后 flush 可执行', async () => {
|
| 68 |
+
let count = 0
|
| 69 |
+
const fc = new FlushController(async () => {
|
| 70 |
+
count += 1
|
| 71 |
+
})
|
| 72 |
+
fc.setCardMessageReady(true)
|
| 73 |
+
await fc.flush()
|
| 74 |
+
expect(count).toBe(1)
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => {
|
| 78 |
+
let count = 0
|
| 79 |
+
const fc = new FlushController(async () => {
|
| 80 |
+
count += 1
|
| 81 |
+
})
|
| 82 |
+
fc.setCardMessageReady(true)
|
| 83 |
+
// 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支
|
| 84 |
+
await fc.throttledUpdate(500)
|
| 85 |
+
// 同步阶段还没到 500ms,不应触发 flush
|
| 86 |
+
expect(count).toBe(0)
|
| 87 |
+
// 500+ms 后延迟 timer 触发
|
| 88 |
+
await sleep(600)
|
| 89 |
+
expect(count).toBe(1)
|
| 90 |
+
})
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
describe('FlushController: complete()', () => {
|
| 94 |
+
it('complete() 后拒绝新 flush', async () => {
|
| 95 |
+
let count = 0
|
| 96 |
+
const fc = new FlushController(async () => {
|
| 97 |
+
count += 1
|
| 98 |
+
})
|
| 99 |
+
fc.setCardMessageReady(true)
|
| 100 |
+
fc.complete()
|
| 101 |
+
await fc.flush()
|
| 102 |
+
await fc.throttledUpdate(50)
|
| 103 |
+
expect(count).toBe(0)
|
| 104 |
+
})
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
// ---------------------------------------------------------------------------
|
| 108 |
+
// Throttle window
|
| 109 |
+
// ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
describe('FlushController: 节流窗口', () => {
|
| 112 |
+
it('超过窗口立即 flush', async () => {
|
| 113 |
+
let count = 0
|
| 114 |
+
const fc = new FlushController(async () => {
|
| 115 |
+
count += 1
|
| 116 |
+
})
|
| 117 |
+
fc.setCardMessageReady(true)
|
| 118 |
+
// 手动把 lastUpdateTime 挪远(等同于已过了节流窗口)
|
| 119 |
+
await sleep(150)
|
| 120 |
+
await fc.throttledUpdate(100)
|
| 121 |
+
expect(count).toBe(1)
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
it('在窗口内首次调用安排延迟 flush', async () => {
|
| 125 |
+
let count = 0
|
| 126 |
+
const fc = new FlushController(async () => {
|
| 127 |
+
count += 1
|
| 128 |
+
})
|
| 129 |
+
fc.setCardMessageReady(true) // lastUpdateTime = now
|
| 130 |
+
|
| 131 |
+
await fc.throttledUpdate(200)
|
| 132 |
+
expect(count).toBe(0) // 延迟中,还没触发
|
| 133 |
+
|
| 134 |
+
await sleep(300)
|
| 135 |
+
expect(count).toBe(1) // 200ms 后延迟 timer 触发
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => {
|
| 139 |
+
let count = 0
|
| 140 |
+
const fc = new FlushController(async () => {
|
| 141 |
+
count += 1
|
| 142 |
+
})
|
| 143 |
+
fc.setCardMessageReady(true)
|
| 144 |
+
|
| 145 |
+
await fc.throttledUpdate(200)
|
| 146 |
+
await fc.throttledUpdate(200)
|
| 147 |
+
await fc.throttledUpdate(200)
|
| 148 |
+
await fc.throttledUpdate(200)
|
| 149 |
+
|
| 150 |
+
await sleep(300)
|
| 151 |
+
expect(count).toBe(1)
|
| 152 |
+
})
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
// ---------------------------------------------------------------------------
|
| 156 |
+
// Mutex + conflict reflush
|
| 157 |
+
// ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
describe('FlushController: mutex + 冲突重刷', () => {
|
| 160 |
+
it('flush 进行中的重复调用不并发执行', async () => {
|
| 161 |
+
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
|
| 162 |
+
const fc = new FlushController(doFlush)
|
| 163 |
+
fc.setCardMessageReady(true)
|
| 164 |
+
|
| 165 |
+
blockNext()
|
| 166 |
+
const p1 = fc.flush() // flush-start 后被 latch 卡住
|
| 167 |
+
// 让事件循环走一轮,确保第一次 flush 进入 body
|
| 168 |
+
await sleep(10)
|
| 169 |
+
// 第二次调用时第一次还没结束 —— 应被 mutex 挡住
|
| 170 |
+
const p2 = fc.flush()
|
| 171 |
+
|
| 172 |
+
// 两次 Promise 都已登记,但都还没 end
|
| 173 |
+
expect(calls).toEqual(['flush-start'])
|
| 174 |
+
|
| 175 |
+
unblock()
|
| 176 |
+
await p1
|
| 177 |
+
await p2
|
| 178 |
+
// 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷
|
| 179 |
+
// (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完)
|
| 180 |
+
await sleep(20)
|
| 181 |
+
|
| 182 |
+
// 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end
|
| 183 |
+
expect(calls).toEqual([
|
| 184 |
+
'flush-start', 'flush-end',
|
| 185 |
+
'flush-start', 'flush-end',
|
| 186 |
+
])
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
it('flush 进行中的 throttledUpdate 也会触发补刷', async () => {
|
| 190 |
+
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
|
| 191 |
+
const fc = new FlushController(doFlush)
|
| 192 |
+
fc.setCardMessageReady(true)
|
| 193 |
+
|
| 194 |
+
blockNext()
|
| 195 |
+
const p1 = fc.flush()
|
| 196 |
+
await sleep(10)
|
| 197 |
+
|
| 198 |
+
// API 进行中收到新的 update 请求
|
| 199 |
+
await fc.throttledUpdate(10)
|
| 200 |
+
|
| 201 |
+
unblock()
|
| 202 |
+
await p1
|
| 203 |
+
await sleep(30)
|
| 204 |
+
|
| 205 |
+
// 第一次 flush + 冲突补刷
|
| 206 |
+
expect(calls.filter((c) => c === 'flush-end').length).toBe(2)
|
| 207 |
+
})
|
| 208 |
+
})
|
| 209 |
+
|
| 210 |
+
// ---------------------------------------------------------------------------
|
| 211 |
+
// Long gap batching
|
| 212 |
+
// ---------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
describe('FlushController: 长间隔批量', () => {
|
| 215 |
+
it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => {
|
| 216 |
+
let count = 0
|
| 217 |
+
let flushAtMs = 0
|
| 218 |
+
const start = Date.now()
|
| 219 |
+
const fc = new FlushController(async () => {
|
| 220 |
+
count += 1
|
| 221 |
+
flushAtMs = Date.now() - start
|
| 222 |
+
})
|
| 223 |
+
fc.setCardMessageReady(true)
|
| 224 |
+
|
| 225 |
+
// 等到 elapsed > 2000ms
|
| 226 |
+
await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50)
|
| 227 |
+
const callAt = Date.now() - start
|
| 228 |
+
|
| 229 |
+
await fc.throttledUpdate(THROTTLE.CARDKIT_MS)
|
| 230 |
+
// throttledUpdate 同步阶段不应立即 flush(因为走批量分支)
|
| 231 |
+
expect(count).toBe(0)
|
| 232 |
+
|
| 233 |
+
// 等 BATCH_AFTER_GAP_MS + 余量
|
| 234 |
+
await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50)
|
| 235 |
+
expect(count).toBe(1)
|
| 236 |
+
// 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms
|
| 237 |
+
expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20)
|
| 238 |
+
})
|
| 239 |
+
})
|
| 240 |
+
|
| 241 |
+
// ---------------------------------------------------------------------------
|
| 242 |
+
// waitForFlush
|
| 243 |
+
// ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
describe('FlushController: waitForFlush', () => {
|
| 246 |
+
it('没在 flush 时立即返回', async () => {
|
| 247 |
+
const fc = new FlushController(async () => {})
|
| 248 |
+
fc.setCardMessageReady(true)
|
| 249 |
+
const start = Date.now()
|
| 250 |
+
await fc.waitForFlush()
|
| 251 |
+
expect(Date.now() - start).toBeLessThan(10)
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
it('有 flush 在跑时等它结束', async () => {
|
| 255 |
+
const { doFlush, blockNext, unblock } = makeControllableFlush()
|
| 256 |
+
const fc = new FlushController(doFlush)
|
| 257 |
+
fc.setCardMessageReady(true)
|
| 258 |
+
|
| 259 |
+
blockNext()
|
| 260 |
+
const p1 = fc.flush()
|
| 261 |
+
await sleep(10)
|
| 262 |
+
|
| 263 |
+
let resolved = false
|
| 264 |
+
const waiter = fc.waitForFlush().then(() => {
|
| 265 |
+
resolved = true
|
| 266 |
+
})
|
| 267 |
+
await sleep(20)
|
| 268 |
+
expect(resolved).toBe(false)
|
| 269 |
+
|
| 270 |
+
unblock()
|
| 271 |
+
await p1
|
| 272 |
+
await waiter
|
| 273 |
+
expect(resolved).toBe(true)
|
| 274 |
+
})
|
| 275 |
+
})
|
| 276 |
+
|
| 277 |
+
// ---------------------------------------------------------------------------
|
| 278 |
+
// 常量合理性
|
| 279 |
+
// ---------------------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
describe('FlushController: THROTTLE 常量', () => {
|
| 282 |
+
it('CARDKIT_MS=100, PATCH_MS=1500', () => {
|
| 283 |
+
expect(THROTTLE.CARDKIT_MS).toBe(100)
|
| 284 |
+
expect(THROTTLE.PATCH_MS).toBe(1500)
|
| 285 |
+
})
|
| 286 |
+
it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => {
|
| 287 |
+
expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000)
|
| 288 |
+
expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300)
|
| 289 |
+
})
|
| 290 |
+
})
|
adapters/feishu/__tests__/markdown-style.test.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* markdown-style 单元测试
|
| 3 |
+
*
|
| 4 |
+
* 覆盖:
|
| 5 |
+
* - 标题降级 (H1~H3 workaround)
|
| 6 |
+
* - 代码块保护
|
| 7 |
+
* - 空行压缩
|
| 8 |
+
* - Schema 2.0: 连续标题/表格/代码块 <br> 间距
|
| 9 |
+
* - stripInvalidImageKeys
|
| 10 |
+
* - sanitizeTextForCard (表格数限制)
|
| 11 |
+
* - findMarkdownTablesOutsideCodeBlocks
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { describe, it, expect } from 'bun:test'
|
| 15 |
+
import {
|
| 16 |
+
optimizeMarkdownForFeishu,
|
| 17 |
+
sanitizeTextForCard,
|
| 18 |
+
findMarkdownTablesOutsideCodeBlocks,
|
| 19 |
+
FEISHU_CARD_TABLE_LIMIT,
|
| 20 |
+
} from '../markdown-style.js'
|
| 21 |
+
|
| 22 |
+
// 默认 cardVersion=2 的 shortcut
|
| 23 |
+
const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v)
|
| 24 |
+
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
// 标题降级
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
describe('optimizeMarkdownForFeishu: 标题降级', () => {
|
| 30 |
+
it('H1 → H4 (cardVersion=1 简化检查)', () => {
|
| 31 |
+
expect(opt('# Title', 1)).toBe('#### Title')
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
it('H2 → H5 (cardVersion=1)', () => {
|
| 35 |
+
expect(opt('## Title', 1)).toBe('##### Title')
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
it('H3 → H5 (cardVersion=1)', () => {
|
| 39 |
+
expect(opt('### Title', 1)).toBe('##### Title')
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => {
|
| 43 |
+
expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3')
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
it('纯 H4 文档不触发降级 (cardVersion=1)', () => {
|
| 47 |
+
// 触发条件: 原文必须有 H1~H3
|
| 48 |
+
expect(opt('#### Already H4', 1)).toBe('#### Already H4')
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => {
|
| 52 |
+
expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub')
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
it('# 后必须有空格才算标题', () => {
|
| 56 |
+
expect(opt('#notaheading', 1)).toBe('#notaheading')
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => {
|
| 60 |
+
// openclaw-lark 源码里的关键注释:顺序不能颠倒
|
| 61 |
+
expect(opt('# Top', 1)).toBe('#### Top')
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
it('无标题文本原样返回', () => {
|
| 65 |
+
expect(opt('just plain text', 1)).toBe('just plain text')
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('默认 cardVersion=2 下也能正确降级标题', () => {
|
| 69 |
+
const out = opt('# Title')
|
| 70 |
+
expect(out).toContain('#### Title')
|
| 71 |
+
expect(out).not.toMatch(/^# Title$/m)
|
| 72 |
+
})
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
// ---------------------------------------------------------------------------
|
| 76 |
+
// 代码块保护
|
| 77 |
+
// ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
|
| 80 |
+
it('代码块内的 # 不被降级 (cardVersion=1)', () => {
|
| 81 |
+
const input = '```\n# not a heading\n## also not\n```'
|
| 82 |
+
expect(opt(input, 1)).toBe(input)
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => {
|
| 86 |
+
const input = '# Real heading\n\n```\n# inside code\n```'
|
| 87 |
+
expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```')
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => {
|
| 91 |
+
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
|
| 92 |
+
expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
it('多个代码块按顺序保护与还原 (cardVersion=1)', () => {
|
| 96 |
+
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
|
| 97 |
+
expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => {
|
| 101 |
+
const out = opt('# Heading\n\n```\n# inside\n```')
|
| 102 |
+
expect(out).toContain('#### Heading') // 外部 H1 降级
|
| 103 |
+
expect(out).toContain('# inside') // 代码块内保留
|
| 104 |
+
expect(out).toContain('```') // fence 保留
|
| 105 |
+
})
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
// ---------------------------------------------------------------------------
|
| 109 |
+
// 空行压缩
|
| 110 |
+
// ---------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
|
| 113 |
+
it('3 个换行 → 2 个 (cardVersion=1)', () => {
|
| 114 |
+
expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2')
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
it('5 个换行 → 2 个 (cardVersion=1)', () => {
|
| 118 |
+
expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2')
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
it('2 个换行保留 (cardVersion=1)', () => {
|
| 122 |
+
expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2')
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
it('代码块内部连续换行被保留 (cardVersion=1)', () => {
|
| 126 |
+
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
|
| 127 |
+
expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
|
| 128 |
+
})
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
// ---------------------------------------------------------------------------
|
| 132 |
+
// Schema 2.0: <br> 间距
|
| 133 |
+
// ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
|
| 136 |
+
it('cardVersion=2 默认在代码块前后加 <br>', () => {
|
| 137 |
+
const out = opt('text\n\n```\ncode\n```')
|
| 138 |
+
// 代码块前后应包裹 <br>
|
| 139 |
+
expect(out).toContain('<br>\n```')
|
| 140 |
+
expect(out).toContain('```\n<br>')
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
it('cardVersion=1 代码块前后不加 <br>', () => {
|
| 144 |
+
const out = opt('text\n\n```\ncode\n```', 1)
|
| 145 |
+
expect(out).not.toContain('<br>')
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
it('cardVersion=2 连续标题之间加 <br>', () => {
|
| 149 |
+
const out = opt('# A\n# B')
|
| 150 |
+
// H1 降级为 H4,之间插入 <br>
|
| 151 |
+
expect(out).toMatch(/#### A\n<br>\n#### B/)
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
it('cardVersion=2 表格前后加 <br>', () => {
|
| 155 |
+
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
| 156 |
+
const out = opt(input)
|
| 157 |
+
// 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
|
| 158 |
+
expect(out).toMatch(/text before\n<br>\n\| col1/)
|
| 159 |
+
// 表格后: <br> 跟两个换行到下一段文本
|
| 160 |
+
expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
it('cardVersion=1 表格前后不加 <br>', () => {
|
| 164 |
+
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
| 165 |
+
const out = opt(input, 1)
|
| 166 |
+
expect(out).not.toContain('<br>')
|
| 167 |
+
})
|
| 168 |
+
|
| 169 |
+
it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => {
|
| 170 |
+
const input = '```\n| in code | not a table |\n|---|---|\n```'
|
| 171 |
+
const out = opt(input)
|
| 172 |
+
// 代码块本体应完整保留
|
| 173 |
+
expect(out).toContain('| in code | not a table |')
|
| 174 |
+
// 代码块外应该没有出现表格 br 标记(因为代码块内不算表格)
|
| 175 |
+
// 代码块本身会被 <br> 包裹(Schema 2.0)但不会在 | 周围单独加 <br>
|
| 176 |
+
expect(out).toMatch(/<br>\n```\n\| in code/)
|
| 177 |
+
})
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
// ---------------------------------------------------------------------------
|
| 181 |
+
// stripInvalidImageKeys
|
| 182 |
+
// ---------------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => {
|
| 185 |
+
it('img_* 图片 key 保留', () => {
|
| 186 |
+
const out = opt('前缀  后缀')
|
| 187 |
+
expect(out).toContain('')
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
it('http:// URL 图片被删除', () => {
|
| 191 |
+
const out = opt('前缀  后缀')
|
| 192 |
+
expect(out).toBe('前缀 后缀')
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
it('https:// URL 图片被删除', () => {
|
| 196 |
+
const out = opt('')
|
| 197 |
+
expect(out).toBe('')
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
it('本地路径被删除', () => {
|
| 201 |
+
const out = opt('')
|
| 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(' 和 ')
|
| 211 |
+
expect(out).toContain('')
|
| 212 |
+
expect(out).not.toContain('bad.com')
|
| 213 |
+
expect(out).not.toContain('![drop]')
|
| 214 |
+
})
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
// ---------------------------------------------------------------------------
|
| 218 |
+
// findMarkdownTablesOutsideCodeBlocks
|
| 219 |
+
// ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
describe('findMarkdownTablesOutsideCodeBlocks', () => {
|
| 222 |
+
it('识别单张表格', () => {
|
| 223 |
+
const text = '| a | b |\n|---|---|\n| 1 | 2 |'
|
| 224 |
+
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
| 225 |
+
expect(matches.length).toBe(1)
|
| 226 |
+
expect(matches[0]!.raw).toContain('| a | b |')
|
| 227 |
+
})
|
| 228 |
+
|
| 229 |
+
it('识别多张表格', () => {
|
| 230 |
+
const text =
|
| 231 |
+
'| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |'
|
| 232 |
+
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
| 233 |
+
expect(matches.length).toBe(2)
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
it('代码块内的 | 不被算作表格', () => {
|
| 237 |
+
const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```'
|
| 238 |
+
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
| 239 |
+
expect(matches.length).toBe(0)
|
| 240 |
+
})
|
| 241 |
+
|
| 242 |
+
it('代码块 + 外部表格: 只识别外部的', () => {
|
| 243 |
+
const text =
|
| 244 |
+
'```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |'
|
| 245 |
+
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
| 246 |
+
expect(matches.length).toBe(1)
|
| 247 |
+
expect(matches[0]!.raw).toContain('real')
|
| 248 |
+
})
|
| 249 |
+
|
| 250 |
+
it('无表格文本返回空数组', () => {
|
| 251 |
+
expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0)
|
| 252 |
+
})
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
// ---------------------------------------------------------------------------
|
| 256 |
+
// sanitizeTextForCard
|
| 257 |
+
// ---------------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
describe('sanitizeTextForCard: 表格数量限制', () => {
|
| 260 |
+
function makeTable(label: string): string {
|
| 261 |
+
return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |`
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
it('表格数 ≤ 3 时原样返回', () => {
|
| 265 |
+
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
| 266 |
+
expect(sanitizeTextForCard(text)).toBe(text)
|
| 267 |
+
})
|
| 268 |
+
|
| 269 |
+
it('恰好 3 张表格原样返回', () => {
|
| 270 |
+
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
| 271 |
+
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
| 272 |
+
expect(matches.length).toBe(3)
|
| 273 |
+
expect(sanitizeTextForCard(text)).toBe(text)
|
| 274 |
+
})
|
| 275 |
+
|
| 276 |
+
it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => {
|
| 277 |
+
const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n')
|
| 278 |
+
const out = sanitizeTextForCard(text)
|
| 279 |
+
// 前 3 张表格原样
|
| 280 |
+
expect(out).toContain(makeTable('A'))
|
| 281 |
+
expect(out).toContain(makeTable('B'))
|
| 282 |
+
expect(out).toContain(makeTable('C'))
|
| 283 |
+
// 第 4 张被包裹
|
| 284 |
+
expect(out).toContain('```\n' + makeTable('D') + '\n```')
|
| 285 |
+
})
|
| 286 |
+
|
| 287 |
+
it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => {
|
| 288 |
+
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
| 289 |
+
const out = sanitizeTextForCard(text, 1)
|
| 290 |
+
expect(out).toContain(makeTable('A'))
|
| 291 |
+
expect(out).toContain('```\n' + makeTable('B') + '\n```')
|
| 292 |
+
expect(out).toContain('```\n' + makeTable('C') + '\n```')
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
it('limit=0: 全部包裹', () => {
|
| 296 |
+
const text = makeTable('Solo')
|
| 297 |
+
const out = sanitizeTextForCard(text, 0)
|
| 298 |
+
expect(out).toContain('```\n' + makeTable('Solo') + '\n```')
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
it('无表格原样返回', () => {
|
| 302 |
+
expect(sanitizeTextForCard('no tables here')).toBe('no tables here')
|
| 303 |
+
})
|
| 304 |
+
|
| 305 |
+
it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => {
|
| 306 |
+
expect(FEISHU_CARD_TABLE_LIMIT).toBe(3)
|
| 307 |
+
})
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
// ---------------------------------------------------------------------------
|
| 311 |
+
// 边界与真实场景
|
| 312 |
+
// ---------------------------------------------------------------------------
|
| 313 |
+
|
| 314 |
+
describe('optimizeMarkdownForFeishu: 边界与真实场景', () => {
|
| 315 |
+
it('screenshot 里的 OpenCutSkill 项目结构报告', () => {
|
| 316 |
+
const input = `## OpenCutSkill 项目架构概览
|
| 317 |
+
|
| 318 |
+
### 1. 项目定位
|
| 319 |
+
|
| 320 |
+
Screen Studio 视频自动剪辑工具。
|
| 321 |
+
|
| 322 |
+
### 2. 模块结构
|
| 323 |
+
|
| 324 |
+
\`\`\`
|
| 325 |
+
opencutskill/
|
| 326 |
+
├── cli/
|
| 327 |
+
├── core/
|
| 328 |
+
└── tests/
|
| 329 |
+
\`\`\``
|
| 330 |
+
const out = opt(input)
|
| 331 |
+
// 所有 H2~H3 应被降级为 H5
|
| 332 |
+
expect(out).toContain('##### OpenCutSkill 项目架构概览')
|
| 333 |
+
expect(out).toContain('##### 1. 项目定位')
|
| 334 |
+
expect(out).toContain('##### 2. 模块结构')
|
| 335 |
+
// 代码块内容原封不动
|
| 336 |
+
expect(out).toContain('opencutskill/')
|
| 337 |
+
expect(out).toContain('├── cli/')
|
| 338 |
+
// 原始 ## 字面量不残留
|
| 339 |
+
expect(out).not.toMatch(/^## OpenCutSkill/m)
|
| 340 |
+
expect(out).not.toMatch(/^### 1\./m)
|
| 341 |
+
// 代码块前后有 <br>(Schema 2.0 默认)
|
| 342 |
+
expect(out).toContain('<br>\n```')
|
| 343 |
+
expect(out).toContain('```\n<br>')
|
| 344 |
+
})
|
| 345 |
+
|
| 346 |
+
it('异常输入 fallback 到原文不抛错', () => {
|
| 347 |
+
expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow()
|
| 348 |
+
})
|
| 349 |
+
|
| 350 |
+
it('空字符串返回空字符串', () => {
|
| 351 |
+
expect(opt('')).toBe('')
|
| 352 |
+
})
|
| 353 |
+
})
|
adapters/feishu/__tests__/media.test.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
| 2 |
+
import * as fs from 'node:fs/promises'
|
| 3 |
+
import * as path from 'node:path'
|
| 4 |
+
import * as os from 'node:os'
|
| 5 |
+
import { FeishuMediaService } from '../media.js'
|
| 6 |
+
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
|
| 7 |
+
|
| 8 |
+
function makeMockClient() {
|
| 9 |
+
return {
|
| 10 |
+
im: {
|
| 11 |
+
messageResource: {
|
| 12 |
+
get: mock(async () => ({
|
| 13 |
+
// node-sdk returns an object with a `.writeFile(path)` helper
|
| 14 |
+
// that dumps the underlying stream. We fake that here.
|
| 15 |
+
writeFile: async (target: string) => {
|
| 16 |
+
await fs.writeFile(target, Buffer.from('DOWNLOADED'))
|
| 17 |
+
},
|
| 18 |
+
})),
|
| 19 |
+
},
|
| 20 |
+
image: {
|
| 21 |
+
create: mock(async (_req: any) => ({
|
| 22 |
+
data: { image_key: 'img_fake_123' },
|
| 23 |
+
})),
|
| 24 |
+
},
|
| 25 |
+
file: {
|
| 26 |
+
create: mock(async (_req: any) => ({
|
| 27 |
+
data: { file_key: 'file_fake_456' },
|
| 28 |
+
})),
|
| 29 |
+
},
|
| 30 |
+
message: {
|
| 31 |
+
create: mock(async (_req: any) => ({
|
| 32 |
+
data: { message_id: 'om_fake' },
|
| 33 |
+
})),
|
| 34 |
+
},
|
| 35 |
+
},
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
let tmpRoot: string
|
| 40 |
+
|
| 41 |
+
beforeEach(async () => {
|
| 42 |
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-'))
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
afterEach(async () => {
|
| 46 |
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
describe('FeishuMediaService', () => {
|
| 50 |
+
it('downloadResource writes a local file and returns LocalAttachment', async () => {
|
| 51 |
+
const client = makeMockClient()
|
| 52 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 53 |
+
const svc = new FeishuMediaService(client as any, store)
|
| 54 |
+
const local = await svc.downloadResource({
|
| 55 |
+
messageId: 'om_msg_1',
|
| 56 |
+
fileKey: 'img_key_1',
|
| 57 |
+
kind: 'image',
|
| 58 |
+
fileName: 'cat.png',
|
| 59 |
+
sessionId: 'sess-1',
|
| 60 |
+
})
|
| 61 |
+
expect(local.kind).toBe('image')
|
| 62 |
+
expect(local.name).toBe('cat.png')
|
| 63 |
+
expect(local.size).toBe('DOWNLOADED'.length)
|
| 64 |
+
expect(local.path).toContain(path.join('feishu', 'sess-1'))
|
| 65 |
+
const onDisk = await fs.readFile(local.path)
|
| 66 |
+
expect(onDisk.toString()).toBe('DOWNLOADED')
|
| 67 |
+
expect(client.im.messageResource.get).toHaveBeenCalledTimes(1)
|
| 68 |
+
const call = (client.im.messageResource.get as any).mock.calls[0][0]
|
| 69 |
+
expect(call.path.message_id).toBe('om_msg_1')
|
| 70 |
+
expect(call.path.file_key).toBe('img_key_1')
|
| 71 |
+
expect(call.params.type).toBe('image')
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
it('uploadImage returns an image_key and sends the buffer through', async () => {
|
| 75 |
+
const client = makeMockClient()
|
| 76 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 77 |
+
const svc = new FeishuMediaService(client as any, store)
|
| 78 |
+
const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png')
|
| 79 |
+
expect(key).toBe('img_fake_123')
|
| 80 |
+
expect(client.im.image.create).toHaveBeenCalledTimes(1)
|
| 81 |
+
const call = (client.im.image.create as any).mock.calls[0][0]
|
| 82 |
+
expect(call.data.image_type).toBe('message')
|
| 83 |
+
expect(call.data.image).toBeDefined()
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
it('uploadFile returns a file_key and uses stream file_type mapping', async () => {
|
| 87 |
+
const client = makeMockClient()
|
| 88 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 89 |
+
const svc = new FeishuMediaService(client as any, store)
|
| 90 |
+
const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf')
|
| 91 |
+
expect(key).toBe('file_fake_456')
|
| 92 |
+
const call = (client.im.file.create as any).mock.calls[0][0]
|
| 93 |
+
expect(call.data.file_name).toBe('report.pdf')
|
| 94 |
+
expect(call.data.file_type).toBe('pdf')
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
it('sendImageMessage posts msg_type=image', async () => {
|
| 98 |
+
const client = makeMockClient()
|
| 99 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 100 |
+
const svc = new FeishuMediaService(client as any, store)
|
| 101 |
+
await svc.sendImageMessage('oc_chat_1', 'img_fake_123')
|
| 102 |
+
const call = (client.im.message.create as any).mock.calls[0][0]
|
| 103 |
+
expect(call.params.receive_id_type).toBe('chat_id')
|
| 104 |
+
expect(call.data.receive_id).toBe('oc_chat_1')
|
| 105 |
+
expect(call.data.msg_type).toBe('image')
|
| 106 |
+
const content = JSON.parse(call.data.content)
|
| 107 |
+
expect(content.image_key).toBe('img_fake_123')
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
it('sendFileMessage posts msg_type=file', async () => {
|
| 111 |
+
const client = makeMockClient()
|
| 112 |
+
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
| 113 |
+
const svc = new FeishuMediaService(client as any, store)
|
| 114 |
+
await svc.sendFileMessage('oc_chat_1', 'file_fake_456')
|
| 115 |
+
const call = (client.im.message.create as any).mock.calls[0][0]
|
| 116 |
+
expect(call.data.msg_type).toBe('file')
|
| 117 |
+
const content = JSON.parse(call.data.content)
|
| 118 |
+
expect(content.file_key).toBe('file_fake_456')
|
| 119 |
+
})
|
| 120 |
+
})
|
adapters/feishu/__tests__/streaming-card.test.ts
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* StreamingCard 生命周期测试
|
| 3 |
+
*
|
| 4 |
+
* 用 mock Lark client 覆盖:
|
| 5 |
+
* - ensureCreated: 成功路径 / 降级路径
|
| 6 |
+
* - appendText: 累积 + 触发 throttled flush
|
| 7 |
+
* - finalize: settings(false) + update 顺序、sequence 单调递增
|
| 8 |
+
* - abort: 渲染错误卡片
|
| 9 |
+
* - 230020 → 跳帧
|
| 10 |
+
* - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
|
| 11 |
+
* - 纯 patch fallback 路径
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { describe, it, expect, beforeEach } from 'bun:test'
|
| 15 |
+
import {
|
| 16 |
+
StreamingCard,
|
| 17 |
+
buildInitialStreamingCard,
|
| 18 |
+
buildRenderedCard,
|
| 19 |
+
buildErrorCard,
|
| 20 |
+
} from '../streaming-card.js'
|
| 21 |
+
import { STREAMING_ELEMENT_ID } from '../cardkit.js'
|
| 22 |
+
|
| 23 |
+
// ---------------------------------------------------------------------------
|
| 24 |
+
// Mock client
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
type ApiCall = { api: string; args: any }
|
| 28 |
+
|
| 29 |
+
type MockBehavior = {
|
| 30 |
+
'card.create'?: any | ((args: any) => any)
|
| 31 |
+
'card.settings'?: any | ((args: any) => any)
|
| 32 |
+
'card.update'?: any | ((args: any) => any)
|
| 33 |
+
'cardElement.content'?: any | ((args: any, callIdx: number) => any)
|
| 34 |
+
'im.message.create'?: any | ((args: any) => any)
|
| 35 |
+
'im.message.reply'?: any | ((args: any) => any)
|
| 36 |
+
'im.message.patch'?: any | ((args: any, callIdx: number) => any)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function makeMockClient(behavior: MockBehavior = {}) {
|
| 40 |
+
const calls: ApiCall[] = []
|
| 41 |
+
let contentCallIdx = 0
|
| 42 |
+
let patchCallIdx = 0
|
| 43 |
+
|
| 44 |
+
function handle(api: string, resp: any, args: any, idx?: number): any {
|
| 45 |
+
calls.push({ api, args })
|
| 46 |
+
if (typeof resp === 'function') return resp(args, idx ?? 0)
|
| 47 |
+
return resp
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const client: any = {
|
| 51 |
+
cardkit: {
|
| 52 |
+
v1: {
|
| 53 |
+
card: {
|
| 54 |
+
create: async (args: any) =>
|
| 55 |
+
handle('cardkit.v1.card.create', behavior['card.create'] ?? {
|
| 56 |
+
code: 0, data: { card_id: 'ck_default' },
|
| 57 |
+
}, args),
|
| 58 |
+
settings: async (args: any) =>
|
| 59 |
+
handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
|
| 60 |
+
update: async (args: any) =>
|
| 61 |
+
handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
|
| 62 |
+
},
|
| 63 |
+
cardElement: {
|
| 64 |
+
content: async (args: any) => {
|
| 65 |
+
const idx = contentCallIdx++
|
| 66 |
+
return handle('cardkit.v1.cardElement.content',
|
| 67 |
+
behavior['cardElement.content'] ?? { code: 0 }, args, idx)
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
im: {
|
| 73 |
+
message: {
|
| 74 |
+
create: async (args: any) =>
|
| 75 |
+
handle('im.message.create', behavior['im.message.create'] ?? {
|
| 76 |
+
data: { message_id: 'om_default' },
|
| 77 |
+
}, args),
|
| 78 |
+
reply: async (args: any) =>
|
| 79 |
+
handle('im.message.reply', behavior['im.message.reply'] ?? {
|
| 80 |
+
data: { message_id: 'om_reply_default' },
|
| 81 |
+
}, args),
|
| 82 |
+
patch: async (args: any) => {
|
| 83 |
+
const idx = patchCallIdx++
|
| 84 |
+
return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
|
| 85 |
+
},
|
| 86 |
+
},
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
return { client, calls }
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async function sleep(ms: number) {
|
| 93 |
+
await new Promise((r) => setTimeout(r, ms))
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---------------------------------------------------------------------------
|
| 97 |
+
// Card JSON builders
|
| 98 |
+
// ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
describe('buildInitialStreamingCard', () => {
|
| 101 |
+
it('Schema 2.0 + streaming_mode + element_id', () => {
|
| 102 |
+
const card = buildInitialStreamingCard() as any
|
| 103 |
+
expect(card.schema).toBe('2.0')
|
| 104 |
+
expect(card.config.streaming_mode).toBe(true)
|
| 105 |
+
// 唯一元素:streaming_content,初始内容为 loading 提示
|
| 106 |
+
const elements = card.body.elements as any[]
|
| 107 |
+
expect(elements.length).toBe(1)
|
| 108 |
+
const streaming = elements[0]
|
| 109 |
+
expect(streaming.tag).toBe('markdown')
|
| 110 |
+
expect(streaming.content).toContain('正在思考中')
|
| 111 |
+
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
|
| 112 |
+
})
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
describe('buildRenderedCard', () => {
|
| 116 |
+
it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
|
| 117 |
+
const card = buildRenderedCard('hello world') as any
|
| 118 |
+
expect(card.schema).toBe('2.0')
|
| 119 |
+
expect(card.config.streaming_mode).toBeUndefined()
|
| 120 |
+
expect(card.body.elements.length).toBe(1)
|
| 121 |
+
const el = card.body.elements[0]
|
| 122 |
+
expect(el.tag).toBe('markdown')
|
| 123 |
+
expect(el.content).toBe('hello world')
|
| 124 |
+
// 最终卡无需 element_id
|
| 125 |
+
expect(el.element_id).toBeUndefined()
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
it('空字符串保底为单空格', () => {
|
| 129 |
+
const card = buildRenderedCard('') as any
|
| 130 |
+
expect(card.body.elements[0].content).toBe(' ')
|
| 131 |
+
})
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
describe('buildErrorCard', () => {
|
| 135 |
+
it('红色 header + markdown body', () => {
|
| 136 |
+
const card = buildErrorCard('oops') as any
|
| 137 |
+
expect((card.header as any).template).toBe('red')
|
| 138 |
+
expect((card.header as any).title.content).toContain('出错')
|
| 139 |
+
expect(card.body.elements[0].content).toBe('oops')
|
| 140 |
+
})
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
// ---------------------------------------------------------------------------
|
| 144 |
+
// StreamingCard lifecycle
|
| 145 |
+
// ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
|
| 148 |
+
it('依次调用 card.create + im.message.create,sequence=1', async () => {
|
| 149 |
+
const { client, calls } = makeMockClient({
|
| 150 |
+
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
|
| 151 |
+
'im.message.create': { data: { message_id: 'om_main_1' } },
|
| 152 |
+
})
|
| 153 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
|
| 154 |
+
await sc.ensureCreated()
|
| 155 |
+
|
| 156 |
+
expect(sc._getPhase()).toBe('streaming')
|
| 157 |
+
expect(sc._getCardId()).toBe('ck_main_1')
|
| 158 |
+
expect(sc._getMessageId()).toBe('om_main_1')
|
| 159 |
+
expect(sc._getSequence()).toBe(1)
|
| 160 |
+
expect(sc._isCardKitStreamActive()).toBe(true)
|
| 161 |
+
|
| 162 |
+
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
| 163 |
+
expect(calls[1]!.api).toBe('im.message.create')
|
| 164 |
+
|
| 165 |
+
// 初始卡 JSON 包含 streaming_mode 和 element_id
|
| 166 |
+
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
| 167 |
+
expect(cardJson.schema).toBe('2.0')
|
| 168 |
+
expect(cardJson.config.streaming_mode).toBe(true)
|
| 169 |
+
// 唯一元素即 streaming_content
|
| 170 |
+
expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
|
| 171 |
+
|
| 172 |
+
// IM message 引用 card_id
|
| 173 |
+
const content = JSON.parse(calls[1]!.args.data.content)
|
| 174 |
+
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
|
| 178 |
+
const { client, calls } = makeMockClient({
|
| 179 |
+
'card.create': { code: 0, data: { card_id: 'ck_1' } },
|
| 180 |
+
'im.message.create': { data: { message_id: 'om_1' } },
|
| 181 |
+
})
|
| 182 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 183 |
+
await sc.ensureCreated()
|
| 184 |
+
await sc.ensureCreated()
|
| 185 |
+
await sc.ensureCreated()
|
| 186 |
+
// 只一次 create + 一次 send
|
| 187 |
+
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
|
| 188 |
+
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
|
| 189 |
+
expect(createCalls.length).toBe(1)
|
| 190 |
+
expect(sendCalls.length).toBe(1)
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
|
| 194 |
+
const { client, calls } = makeMockClient({
|
| 195 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 196 |
+
'im.message.reply': { data: { message_id: 'om_reply' } },
|
| 197 |
+
})
|
| 198 |
+
const sc = new StreamingCard({
|
| 199 |
+
larkClient: client,
|
| 200 |
+
chatId: 'c',
|
| 201 |
+
replyToMessageId: 'om_parent',
|
| 202 |
+
})
|
| 203 |
+
await sc.ensureCreated()
|
| 204 |
+
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
|
| 205 |
+
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
|
| 206 |
+
expect(sc._getMessageId()).toBe('om_reply')
|
| 207 |
+
})
|
| 208 |
+
})
|
| 209 |
+
|
| 210 |
+
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
|
| 211 |
+
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
|
| 212 |
+
const { client, calls } = makeMockClient({
|
| 213 |
+
'card.create': { code: 99991672, msg: 'permission denied' },
|
| 214 |
+
'im.message.create': { data: { message_id: 'om_fb' } },
|
| 215 |
+
})
|
| 216 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 217 |
+
await sc.ensureCreated()
|
| 218 |
+
|
| 219 |
+
expect(sc._getPhase()).toBe('streaming')
|
| 220 |
+
expect(sc._getCardId()).toBeNull()
|
| 221 |
+
expect(sc._getMessageId()).toBe('om_fb')
|
| 222 |
+
expect(sc._isCardKitStreamActive()).toBe(false)
|
| 223 |
+
|
| 224 |
+
// fallback 发送的是 Schema 2.0 interactive 卡
|
| 225 |
+
const createCall = calls.find((c) => c.api === 'im.message.create')
|
| 226 |
+
expect(createCall).toBeDefined()
|
| 227 |
+
expect(createCall!.args.data.msg_type).toBe('interactive')
|
| 228 |
+
const cardContent = JSON.parse(createCall!.args.data.content)
|
| 229 |
+
expect(cardContent.schema).toBe('2.0')
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
|
| 233 |
+
let sendCallCount = 0
|
| 234 |
+
const { client } = makeMockClient({
|
| 235 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 236 |
+
'im.message.create': () => {
|
| 237 |
+
sendCallCount++
|
| 238 |
+
if (sendCallCount === 1) throw new Error('send failed')
|
| 239 |
+
return { data: { message_id: 'om_fb2' } }
|
| 240 |
+
},
|
| 241 |
+
})
|
| 242 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 243 |
+
await sc.ensureCreated()
|
| 244 |
+
expect(sc._getPhase()).toBe('streaming')
|
| 245 |
+
expect(sc._getCardId()).toBeNull()
|
| 246 |
+
expect(sc._getMessageId()).toBe('om_fb2')
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
+
it('降级发送也失败 → aborted + throw', async () => {
|
| 250 |
+
const { client } = makeMockClient({
|
| 251 |
+
'card.create': { code: 99991672 },
|
| 252 |
+
'im.message.create': () => {
|
| 253 |
+
throw new Error('really broken')
|
| 254 |
+
},
|
| 255 |
+
})
|
| 256 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 257 |
+
await expect(sc.ensureCreated()).rejects.toThrow()
|
| 258 |
+
expect(sc._getPhase()).toBe('aborted')
|
| 259 |
+
})
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
// ---------------------------------------------------------------------------
|
| 263 |
+
// appendText + flush
|
| 264 |
+
// ---------------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
describe('StreamingCard: appendText + flush', () => {
|
| 267 |
+
it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
|
| 268 |
+
const { client, calls } = makeMockClient({
|
| 269 |
+
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
|
| 270 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 271 |
+
})
|
| 272 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 273 |
+
await sc.ensureCreated()
|
| 274 |
+
|
| 275 |
+
// 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
|
| 276 |
+
sc.appendText('Hello ')
|
| 277 |
+
sc.appendText('world')
|
| 278 |
+
|
| 279 |
+
// 节流窗口 100ms + 余量
|
| 280 |
+
await sleep(150)
|
| 281 |
+
|
| 282 |
+
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 283 |
+
expect(contentCalls.length).toBeGreaterThan(0)
|
| 284 |
+
// 最后一次 flush 的内容应包含完整累积文本
|
| 285 |
+
const lastCall = contentCalls[contentCalls.length - 1]!
|
| 286 |
+
expect(lastCall.args.data.content).toContain('Hello world')
|
| 287 |
+
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
|
| 288 |
+
// sequence 严格单调递增
|
| 289 |
+
const seqs = contentCalls.map((c) => c.args.data.sequence)
|
| 290 |
+
for (let i = 1; i < seqs.length; i++) {
|
| 291 |
+
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
|
| 292 |
+
}
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
|
| 296 |
+
const { client, calls } = makeMockClient({
|
| 297 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 298 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 299 |
+
})
|
| 300 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 301 |
+
await sc.ensureCreated()
|
| 302 |
+
|
| 303 |
+
sc.appendText('same')
|
| 304 |
+
await sleep(150)
|
| 305 |
+
|
| 306 |
+
// 强制再跑一次 flush(无新文本)
|
| 307 |
+
await sc._getFlushController().flush()
|
| 308 |
+
|
| 309 |
+
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 310 |
+
// 应该只有一次 content 调用
|
| 311 |
+
expect(contentCalls.length).toBe(1)
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
it('completed 之后的 appendText 被忽略', async () => {
|
| 315 |
+
const { client } = makeMockClient({
|
| 316 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 317 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 318 |
+
})
|
| 319 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 320 |
+
await sc.ensureCreated()
|
| 321 |
+
await sc.finalize()
|
| 322 |
+
sc.appendText('ignored')
|
| 323 |
+
expect(sc._getAccumulatedText()).toBe('')
|
| 324 |
+
})
|
| 325 |
+
})
|
| 326 |
+
|
| 327 |
+
// ---------------------------------------------------------------------------
|
| 328 |
+
// finalize
|
| 329 |
+
// ---------------------------------------------------------------------------
|
| 330 |
+
|
| 331 |
+
describe('StreamingCard: finalize', () => {
|
| 332 |
+
it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
|
| 333 |
+
const { client, calls } = makeMockClient({
|
| 334 |
+
'card.create': { code: 0, data: { card_id: 'ck_final' } },
|
| 335 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 336 |
+
})
|
| 337 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 338 |
+
await sc.ensureCreated()
|
| 339 |
+
sc.appendText('# Title\n\nBody')
|
| 340 |
+
await sleep(150)
|
| 341 |
+
const contentSeqs = calls
|
| 342 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 343 |
+
.map((c) => c.args.data.sequence)
|
| 344 |
+
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
|
| 345 |
+
|
| 346 |
+
await sc.finalize()
|
| 347 |
+
|
| 348 |
+
expect(sc._getPhase()).toBe('completed')
|
| 349 |
+
|
| 350 |
+
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
|
| 351 |
+
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
| 352 |
+
expect(settingsCalls.length).toBe(1)
|
| 353 |
+
expect(updateCalls.length).toBe(1)
|
| 354 |
+
|
| 355 |
+
const settingsSeq = settingsCalls[0]!.args.data.sequence
|
| 356 |
+
const updateSeq = updateCalls[0]!.args.data.sequence
|
| 357 |
+
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
|
| 358 |
+
expect(updateSeq).toBeGreaterThan(settingsSeq)
|
| 359 |
+
|
| 360 |
+
// settings 关闭 streaming_mode
|
| 361 |
+
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
|
| 362 |
+
expect(settings.streaming_mode).toBe(false)
|
| 363 |
+
|
| 364 |
+
// update 卡内容是预处理后的 markdown
|
| 365 |
+
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
|
| 366 |
+
const finalContent = finalCardJson.body.elements[0].content
|
| 367 |
+
// H1 被降级为 H4
|
| 368 |
+
expect(finalContent).toContain('#### Title')
|
| 369 |
+
expect(finalContent).toContain('Body')
|
| 370 |
+
})
|
| 371 |
+
|
| 372 |
+
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
|
| 373 |
+
const { client, calls } = makeMockClient({
|
| 374 |
+
'card.create': { code: 99991672 },
|
| 375 |
+
'im.message.create': { data: { message_id: 'om_fb' } },
|
| 376 |
+
})
|
| 377 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 378 |
+
await sc.ensureCreated()
|
| 379 |
+
sc.appendText('## Heading\n\nContent')
|
| 380 |
+
await sleep(1600) // 等 PATCH_MS 窗口
|
| 381 |
+
await sc.finalize()
|
| 382 |
+
|
| 383 |
+
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
|
| 384 |
+
expect(patchCalls.length).toBeGreaterThan(0)
|
| 385 |
+
// 最后一次 patch 是 finalize 的(full final card)
|
| 386 |
+
const lastPatch = patchCalls[patchCalls.length - 1]!
|
| 387 |
+
const finalCard = JSON.parse(lastPatch.args.data.content)
|
| 388 |
+
const finalContent = finalCard.body.elements[0].content
|
| 389 |
+
// ## → ##### 降级
|
| 390 |
+
expect(finalContent).toContain('##### Heading')
|
| 391 |
+
})
|
| 392 |
+
|
| 393 |
+
it('完全 idle 时 finalize ��接标记 completed 不抛错', async () => {
|
| 394 |
+
const { client } = makeMockClient()
|
| 395 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 396 |
+
await sc.finalize()
|
| 397 |
+
expect(sc._getPhase()).toBe('completed')
|
| 398 |
+
})
|
| 399 |
+
|
| 400 |
+
it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => {
|
| 401 |
+
const { client, calls } = makeMockClient({
|
| 402 |
+
'card.create': { code: 0, data: { card_id: 'ck_term' } },
|
| 403 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 404 |
+
})
|
| 405 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 406 |
+
await sc.ensureCreated()
|
| 407 |
+
|
| 408 |
+
// 同时塞入三种内容
|
| 409 |
+
sc.appendReasoning('Let me think about this problem carefully...')
|
| 410 |
+
sc.startTool('tu_1', 'Read')
|
| 411 |
+
sc.completeTool('tu_1', 'Read')
|
| 412 |
+
sc.appendText('## 答复\n\n这是最终答复正文。')
|
| 413 |
+
await sleep(150)
|
| 414 |
+
|
| 415 |
+
// 流式中间帧应该包含 reasoning + tools + answer 全套
|
| 416 |
+
const lastMidFrame = calls
|
| 417 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 418 |
+
.pop()!.args.data.content as string
|
| 419 |
+
expect(lastMidFrame).toContain('思考中')
|
| 420 |
+
expect(lastMidFrame).toContain('Read')
|
| 421 |
+
expect(lastMidFrame).toContain('最终答复正文')
|
| 422 |
+
|
| 423 |
+
await sc.finalize()
|
| 424 |
+
|
| 425 |
+
// finalize 用的是 card.update,把整张卡换成只有 answer 的版本
|
| 426 |
+
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
|
| 427 |
+
const finalCardJson = JSON.parse(updateCall.args.data.card.data)
|
| 428 |
+
const finalContent = finalCardJson.body.elements[0].content as string
|
| 429 |
+
|
| 430 |
+
expect(finalContent).toContain('最终答复正文')
|
| 431 |
+
// H2 → 降级 H5
|
| 432 |
+
expect(finalContent).toContain('##### 答复')
|
| 433 |
+
// reasoning + tools 都不应该出现在终态
|
| 434 |
+
expect(finalContent).not.toContain('思考中')
|
| 435 |
+
expect(finalContent).not.toContain('think about this problem')
|
| 436 |
+
expect(finalContent).not.toContain('Read')
|
| 437 |
+
expect(finalContent).not.toContain('🛠️')
|
| 438 |
+
expect(finalContent).not.toContain('💭')
|
| 439 |
+
})
|
| 440 |
+
|
| 441 |
+
it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
|
| 442 |
+
const { client, calls } = makeMockClient({
|
| 443 |
+
'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
|
| 444 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 445 |
+
})
|
| 446 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 447 |
+
await sc.ensureCreated()
|
| 448 |
+
|
| 449 |
+
// 只有推理,没有 appendText —— 异常 case 但要可控降级
|
| 450 |
+
sc.appendReasoning('I was thinking but never produced an answer.')
|
| 451 |
+
await sleep(150)
|
| 452 |
+
|
| 453 |
+
await sc.finalize()
|
| 454 |
+
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
|
| 455 |
+
const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
|
| 456 |
+
// 至少能看到推理内容
|
| 457 |
+
expect(finalContent).toContain('thinking')
|
| 458 |
+
})
|
| 459 |
+
|
| 460 |
+
it('finalize 失败不抛出', async () => {
|
| 461 |
+
const { client } = makeMockClient({
|
| 462 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 463 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 464 |
+
'card.settings': () => {
|
| 465 |
+
throw new Error('settings exploded')
|
| 466 |
+
},
|
| 467 |
+
})
|
| 468 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 469 |
+
await sc.ensureCreated()
|
| 470 |
+
sc.appendText('text')
|
| 471 |
+
await sleep(150)
|
| 472 |
+
// finalize 内部捕获错误不 rethrow
|
| 473 |
+
await sc.finalize()
|
| 474 |
+
expect(sc._getPhase()).toBe('completed')
|
| 475 |
+
})
|
| 476 |
+
})
|
| 477 |
+
|
| 478 |
+
// ---------------------------------------------------------------------------
|
| 479 |
+
// Rate limit + table limit
|
| 480 |
+
// ---------------------------------------------------------------------------
|
| 481 |
+
|
| 482 |
+
describe('StreamingCard: 错误处理', () => {
|
| 483 |
+
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
|
| 484 |
+
let callIdx = 0
|
| 485 |
+
const { client, calls } = makeMockClient({
|
| 486 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 487 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 488 |
+
'cardElement.content': () => {
|
| 489 |
+
const i = callIdx++
|
| 490 |
+
if (i === 0) {
|
| 491 |
+
const err: any = new Error('rate limit')
|
| 492 |
+
err.code = 230020
|
| 493 |
+
throw err
|
| 494 |
+
}
|
| 495 |
+
return { code: 0 }
|
| 496 |
+
},
|
| 497 |
+
})
|
| 498 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 499 |
+
await sc.ensureCreated()
|
| 500 |
+
sc.appendText('first')
|
| 501 |
+
await sleep(150)
|
| 502 |
+
// 第一次被限流
|
| 503 |
+
sc.appendText(' second')
|
| 504 |
+
await sleep(150)
|
| 505 |
+
// 第二次应能成功
|
| 506 |
+
|
| 507 |
+
// CardKit 仍然 active(没降级)
|
| 508 |
+
expect(sc._isCardKitStreamActive()).toBe(true)
|
| 509 |
+
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 510 |
+
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
| 511 |
+
})
|
| 512 |
+
|
| 513 |
+
it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
|
| 514 |
+
const { client, calls } = makeMockClient({
|
| 515 |
+
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
|
| 516 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 517 |
+
'cardElement.content': () => {
|
| 518 |
+
const err: any = new Error('content failed')
|
| 519 |
+
err.code = 230099
|
| 520 |
+
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
|
| 521 |
+
throw err
|
| 522 |
+
},
|
| 523 |
+
})
|
| 524 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 525 |
+
await sc.ensureCreated()
|
| 526 |
+
sc.appendText('some content')
|
| 527 |
+
await sleep(150)
|
| 528 |
+
expect(sc._isCardKitStreamActive()).toBe(false)
|
| 529 |
+
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
|
| 530 |
+
|
| 531 |
+
await sc.finalize()
|
| 532 |
+
// finalize 仍然走 CardKit 的 settings + update(cardId 还在)
|
| 533 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
| 534 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
| 535 |
+
// 不走 patch
|
| 536 |
+
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
|
| 537 |
+
})
|
| 538 |
+
|
| 539 |
+
it('CardKit 中间帧请求挂住时不会阻塞 message_complete 收尾', async () => {
|
| 540 |
+
const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 541 |
+
process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20'
|
| 542 |
+
try {
|
| 543 |
+
const { client, calls } = makeMockClient({
|
| 544 |
+
'card.create': { code: 0, data: { card_id: 'ck_hung' } },
|
| 545 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 546 |
+
'cardElement.content': () => new Promise(() => {}),
|
| 547 |
+
})
|
| 548 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 549 |
+
await sc.ensureCreated()
|
| 550 |
+
|
| 551 |
+
sc.appendText('partial text')
|
| 552 |
+
await sleep(60)
|
| 553 |
+
|
| 554 |
+
const completed = await Promise.race([
|
| 555 |
+
sc.finalize().then(() => true),
|
| 556 |
+
sleep(250).then(() => false),
|
| 557 |
+
])
|
| 558 |
+
|
| 559 |
+
expect(completed).toBe(true)
|
| 560 |
+
expect(sc._getPhase()).toBe('completed')
|
| 561 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
| 562 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
| 563 |
+
} finally {
|
| 564 |
+
if (previousTimeout === undefined) {
|
| 565 |
+
delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 566 |
+
} else {
|
| 567 |
+
process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
})
|
| 571 |
+
})
|
| 572 |
+
|
| 573 |
+
// ---------------------------------------------------------------------------
|
| 574 |
+
// abort
|
| 575 |
+
// ---------------------------------------------------------------------------
|
| 576 |
+
|
| 577 |
+
describe('StreamingCard: abort', () => {
|
| 578 |
+
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
|
| 579 |
+
const { client, calls } = makeMockClient({
|
| 580 |
+
'card.create': { code: 0, data: { card_id: 'ck_err' } },
|
| 581 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 582 |
+
})
|
| 583 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 584 |
+
await sc.ensureCreated()
|
| 585 |
+
sc.appendText('partial...')
|
| 586 |
+
await sleep(150)
|
| 587 |
+
|
| 588 |
+
await sc.abort(new Error('something went wrong'))
|
| 589 |
+
expect(sc._getPhase()).toBe('aborted')
|
| 590 |
+
|
| 591 |
+
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
| 592 |
+
expect(updateCalls.length).toBeGreaterThan(0)
|
| 593 |
+
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
|
| 594 |
+
expect(errCard.header.template).toBe('red')
|
| 595 |
+
expect(errCard.body.elements[0].content).toContain('something went wrong')
|
| 596 |
+
// 保留已累积的部分文本
|
| 597 |
+
expect(errCard.body.elements[0].content).toContain('partial...')
|
| 598 |
+
})
|
| 599 |
+
|
| 600 |
+
it('idle 阶段 abort 不抛错', async () => {
|
| 601 |
+
const { client } = makeMockClient()
|
| 602 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 603 |
+
await sc.abort(new Error('before any card'))
|
| 604 |
+
expect(sc._getPhase()).toBe('aborted')
|
| 605 |
+
})
|
| 606 |
+
})
|
| 607 |
+
|
| 608 |
+
// ---------------------------------------------------------------------------
|
| 609 |
+
// Reasoning / tool use rendering
|
| 610 |
+
// ---------------------------------------------------------------------------
|
| 611 |
+
|
| 612 |
+
describe('StreamingCard: appendReasoning', () => {
|
| 613 |
+
it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => {
|
| 614 |
+
const { client, calls } = makeMockClient({
|
| 615 |
+
'card.create': { code: 0, data: { card_id: 'ck_think' } },
|
| 616 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 617 |
+
})
|
| 618 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 619 |
+
await sc.ensureCreated()
|
| 620 |
+
|
| 621 |
+
sc.appendReasoning('Analyzing the problem. ')
|
| 622 |
+
sc.appendReasoning('Let me check file A.')
|
| 623 |
+
await sleep(150)
|
| 624 |
+
|
| 625 |
+
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 626 |
+
expect(contentCalls.length).toBeGreaterThan(0)
|
| 627 |
+
const last = contentCalls[contentCalls.length - 1]!
|
| 628 |
+
expect(last.args.data.content).toContain('💭')
|
| 629 |
+
expect(last.args.data.content).toContain('思考中')
|
| 630 |
+
expect(last.args.data.content).toContain('Analyzing the problem.')
|
| 631 |
+
expect(last.args.data.content).toContain('Let me check file A.')
|
| 632 |
+
// 没有 blockquote `>` 前缀 —— 这是新格式的关键
|
| 633 |
+
expect(last.args.data.content).not.toContain('> Analyzing')
|
| 634 |
+
// 没有 appendText → 不应有普通正文
|
| 635 |
+
expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
|
| 636 |
+
expect(sc._getAccumulatedText()).toBe('')
|
| 637 |
+
})
|
| 638 |
+
|
| 639 |
+
it('completed 之后 appendReasoning 被忽略', async () => {
|
| 640 |
+
const { client } = makeMockClient({
|
| 641 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 642 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 643 |
+
})
|
| 644 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 645 |
+
await sc.ensureCreated()
|
| 646 |
+
await sc.finalize()
|
| 647 |
+
sc.appendReasoning('too late')
|
| 648 |
+
expect(sc._getAccumulatedReasoning()).toBe('')
|
| 649 |
+
})
|
| 650 |
+
})
|
| 651 |
+
|
| 652 |
+
describe('StreamingCard: startTool / completeTool', () => {
|
| 653 |
+
it('startTool 压入 running 步骤,completeTool 翻到 done', async () => {
|
| 654 |
+
const { client, calls } = makeMockClient({
|
| 655 |
+
'card.create': { code: 0, data: { card_id: 'ck_tool' } },
|
| 656 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 657 |
+
})
|
| 658 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 659 |
+
await sc.ensureCreated()
|
| 660 |
+
|
| 661 |
+
sc.startTool('tu_1', 'Read')
|
| 662 |
+
await sleep(150)
|
| 663 |
+
let steps = sc._getToolSteps()
|
| 664 |
+
expect(steps.length).toBe(1)
|
| 665 |
+
expect(steps[0]!.name).toBe('Read')
|
| 666 |
+
expect(steps[0]!.status).toBe('running')
|
| 667 |
+
|
| 668 |
+
// 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式)
|
| 669 |
+
const runningContent = calls
|
| 670 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 671 |
+
.map((c) => c.args.data.content)
|
| 672 |
+
.join('\n')
|
| 673 |
+
expect(runningContent).toContain('⚙️')
|
| 674 |
+
expect(runningContent).toContain('Read')
|
| 675 |
+
expect(runningContent).toContain('🛠️')
|
| 676 |
+
|
| 677 |
+
sc.completeTool('tu_1', 'Read')
|
| 678 |
+
await sleep(150)
|
| 679 |
+
steps = sc._getToolSteps()
|
| 680 |
+
expect(steps[0]!.status).toBe('done')
|
| 681 |
+
|
| 682 |
+
// 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
|
| 683 |
+
const lastContent = calls
|
| 684 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 685 |
+
.pop()!.args.data.content as string
|
| 686 |
+
expect(lastContent).toContain('✅')
|
| 687 |
+
expect(lastContent).toContain('Read')
|
| 688 |
+
// 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
|
| 689 |
+
expect(lastContent).not.toContain('⚙️')
|
| 690 |
+
})
|
| 691 |
+
|
| 692 |
+
it('按 toolUseId 去重: 同一 id 不重复压入', async () => {
|
| 693 |
+
const { client } = makeMockClient({
|
| 694 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 695 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 696 |
+
})
|
| 697 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 698 |
+
await sc.ensureCreated()
|
| 699 |
+
|
| 700 |
+
sc.startTool('tu_1', 'Read')
|
| 701 |
+
sc.startTool('tu_1', 'Read')
|
| 702 |
+
sc.startTool('tu_1', 'Read')
|
| 703 |
+
expect(sc._getToolSteps().length).toBe(1)
|
| 704 |
+
})
|
| 705 |
+
|
| 706 |
+
it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => {
|
| 707 |
+
const { client } = makeMockClient({
|
| 708 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 709 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 710 |
+
})
|
| 711 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 712 |
+
await sc.ensureCreated()
|
| 713 |
+
|
| 714 |
+
sc.startTool(undefined, 'Read')
|
| 715 |
+
sc.startTool(undefined, 'Read')
|
| 716 |
+
// 合成 id 不同 → 两个独立步骤
|
| 717 |
+
expect(sc._getToolSteps().length).toBe(2)
|
| 718 |
+
})
|
| 719 |
+
|
| 720 |
+
it('completeTool 只匹配最近的 running 同名步骤', async () => {
|
| 721 |
+
const { client } = makeMockClient({
|
| 722 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 723 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 724 |
+
})
|
| 725 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 726 |
+
await sc.ensureCreated()
|
| 727 |
+
|
| 728 |
+
sc.startTool('tu_1', 'Bash')
|
| 729 |
+
sc.startTool('tu_2', 'Bash')
|
| 730 |
+
sc.completeTool(undefined, 'Bash')
|
| 731 |
+
const steps = sc._getToolSteps()
|
| 732 |
+
// 更晚的 tu_2 被标记 done
|
| 733 |
+
expect(steps[0]!.status).toBe('running')
|
| 734 |
+
expect(steps[1]!.status).toBe('done')
|
| 735 |
+
})
|
| 736 |
+
|
| 737 |
+
it('空 toolName 忽略', async () => {
|
| 738 |
+
const { client } = makeMockClient({
|
| 739 |
+
'card.create': { code: 0, data: { card_id: 'ck' } },
|
| 740 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 741 |
+
})
|
| 742 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 743 |
+
await sc.ensureCreated()
|
| 744 |
+
|
| 745 |
+
sc.startTool('tu_1', undefined)
|
| 746 |
+
sc.startTool('tu_1', '')
|
| 747 |
+
expect(sc._getToolSteps().length).toBe(0)
|
| 748 |
+
})
|
| 749 |
+
})
|
| 750 |
+
|
| 751 |
+
// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
|
| 752 |
+
// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
|
| 753 |
+
// gate / 等任何东西吃掉)。
|
| 754 |
+
describe('StreamingCard: 真实事件流(用户场景回归)', () => {
|
| 755 |
+
it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
|
| 756 |
+
const { client, calls } = makeMockClient({
|
| 757 |
+
'card.create': { code: 0, data: { card_id: 'ck_real' } },
|
| 758 |
+
'im.message.create': { data: { message_id: 'om_real' } },
|
| 759 |
+
})
|
| 760 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
|
| 761 |
+
|
| 762 |
+
// 1. 用户发消息 → handleMessage 预建卡(fire-and-forget)
|
| 763 |
+
const creating = sc.ensureCreated()
|
| 764 |
+
await creating // 等卡可写
|
| 765 |
+
|
| 766 |
+
// 2. 服务端: status streaming + content_start{text} (thinking block)
|
| 767 |
+
// feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op��
|
| 768 |
+
// (no direct call here — 等同于 no-op)
|
| 769 |
+
|
| 770 |
+
// 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式)
|
| 771 |
+
sc.appendReasoning('Analyzing the latest commits to find ')
|
| 772 |
+
await sleep(30)
|
| 773 |
+
sc.appendReasoning('breaking changes. Need to look at ')
|
| 774 |
+
await sleep(30)
|
| 775 |
+
sc.appendReasoning('the public API surface, the schema files, ')
|
| 776 |
+
await sleep(30)
|
| 777 |
+
sc.appendReasoning('and any removed exports. Let me check the ')
|
| 778 |
+
await sleep(30)
|
| 779 |
+
sc.appendReasoning('git log first.')
|
| 780 |
+
|
| 781 |
+
// 等节流窗口结束
|
| 782 |
+
await sleep(200)
|
| 783 |
+
|
| 784 |
+
const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
|
| 785 |
+
expect(flushesAfterReasoning).toBeGreaterThan(0)
|
| 786 |
+
|
| 787 |
+
const lastReasoningContent = calls
|
| 788 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 789 |
+
.pop()!.args.data.content as string
|
| 790 |
+
// 应该包含 reasoning 累积内容
|
| 791 |
+
expect(lastReasoningContent).toContain('breaking changes')
|
| 792 |
+
expect(lastReasoningContent).toContain('git log first')
|
| 793 |
+
|
| 794 |
+
// 4. 服务端: content_start{tool_use, name: 'Bash'}
|
| 795 |
+
sc.startTool('tu_bash_1', 'Bash')
|
| 796 |
+
await sleep(150)
|
| 797 |
+
|
| 798 |
+
const lastWithTool = calls
|
| 799 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 800 |
+
.pop()!.args.data.content as string
|
| 801 |
+
expect(lastWithTool).toContain('Bash')
|
| 802 |
+
expect(lastWithTool).toContain('⚙️')
|
| 803 |
+
expect(lastWithTool).toContain('🛠️')
|
| 804 |
+
|
| 805 |
+
// 5. 服务端: tool_use_complete
|
| 806 |
+
sc.completeTool('tu_bash_1', 'Bash')
|
| 807 |
+
await sleep(150)
|
| 808 |
+
|
| 809 |
+
const lastAfterToolDone = calls
|
| 810 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 811 |
+
.pop()!.args.data.content as string
|
| 812 |
+
expect(lastAfterToolDone).toContain('Bash')
|
| 813 |
+
// ⚙️ 切到 ✅ —— 当前唯一一步已完成
|
| 814 |
+
expect(lastAfterToolDone).toContain('✅')
|
| 815 |
+
expect(lastAfterToolDone).not.toContain('⚙️')
|
| 816 |
+
|
| 817 |
+
// 6. 第二个 tool 序列
|
| 818 |
+
sc.startTool('tu_read_1', 'Read')
|
| 819 |
+
await sleep(150)
|
| 820 |
+
sc.completeTool('tu_read_1', 'Read')
|
| 821 |
+
await sleep(150)
|
| 822 |
+
|
| 823 |
+
// 7. 最终 text 输出
|
| 824 |
+
sc.appendText('## 破坏性变更分析\n\n')
|
| 825 |
+
await sleep(120)
|
| 826 |
+
sc.appendText('1. **API 重命名**: foo → bar\n')
|
| 827 |
+
await sleep(120)
|
| 828 |
+
sc.appendText('2. **删除导出**: baz')
|
| 829 |
+
await sleep(200)
|
| 830 |
+
|
| 831 |
+
const lastWithText = calls
|
| 832 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 833 |
+
.pop()!.args.data.content as string
|
| 834 |
+
// 应该同时包含 reasoning, tools, answer
|
| 835 |
+
expect(lastWithText).toContain('git log first') // reasoning
|
| 836 |
+
expect(lastWithText).toContain('Bash') // tool
|
| 837 |
+
expect(lastWithText).toContain('Read') // tool
|
| 838 |
+
expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
|
| 839 |
+
expect(lastWithText).toContain('API 重命名')
|
| 840 |
+
|
| 841 |
+
// 8. message_complete → finalize
|
| 842 |
+
await sc.finalize()
|
| 843 |
+
expect(sc._getPhase()).toBe('completed')
|
| 844 |
+
|
| 845 |
+
// 验证有 settings + update 收尾
|
| 846 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
| 847 |
+
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
| 848 |
+
})
|
| 849 |
+
|
| 850 |
+
it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
|
| 851 |
+
let firstFrameRejected = false
|
| 852 |
+
const { client, calls } = makeMockClient({
|
| 853 |
+
'card.create': { code: 0, data: { card_id: 'ck_recover' } },
|
| 854 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 855 |
+
'cardElement.content': () => {
|
| 856 |
+
if (!firstFrameRejected) {
|
| 857 |
+
firstFrameRejected = true
|
| 858 |
+
// 模拟一个 *非* rate-limit、*非* table-limit 错误
|
| 859 |
+
// 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题
|
| 860 |
+
const err: any = new Error('mystery cardkit error')
|
| 861 |
+
err.code = 999999
|
| 862 |
+
throw err
|
| 863 |
+
}
|
| 864 |
+
return { code: 0 }
|
| 865 |
+
},
|
| 866 |
+
})
|
| 867 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 868 |
+
await sc.ensureCreated()
|
| 869 |
+
|
| 870 |
+
sc.appendReasoning('first thought')
|
| 871 |
+
await sleep(150)
|
| 872 |
+
// 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
|
| 873 |
+
sc.appendReasoning(' second thought')
|
| 874 |
+
await sleep(150)
|
| 875 |
+
// 验证: 至少尝试了 2 次 cardElement.content 调用
|
| 876 |
+
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 877 |
+
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
| 878 |
+
// 而且 streaming 仍是 active
|
| 879 |
+
expect(sc._isCardKitStreamActive()).toBe(true)
|
| 880 |
+
})
|
| 881 |
+
})
|
| 882 |
+
|
| 883 |
+
describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
|
| 884 |
+
it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
|
| 885 |
+
const { client, calls } = makeMockClient({
|
| 886 |
+
'card.create': { code: 0, data: { card_id: 'ck_all' } },
|
| 887 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 888 |
+
})
|
| 889 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 890 |
+
await sc.ensureCreated()
|
| 891 |
+
|
| 892 |
+
sc.appendReasoning('Should I read file A first?')
|
| 893 |
+
sc.startTool('tu_1', 'Read')
|
| 894 |
+
sc.appendText('Here is the answer.')
|
| 895 |
+
await sleep(150)
|
| 896 |
+
|
| 897 |
+
const lastContent = calls
|
| 898 |
+
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 899 |
+
.pop()!.args.data.content as string
|
| 900 |
+
|
| 901 |
+
const idxTools = lastContent.indexOf('🛠️')
|
| 902 |
+
const idxReasoning = lastContent.indexOf('思考中')
|
| 903 |
+
const idxAnswer = lastContent.indexOf('Here is the answer')
|
| 904 |
+
|
| 905 |
+
expect(idxTools).toBeGreaterThan(-1)
|
| 906 |
+
expect(idxReasoning).toBeGreaterThan(-1)
|
| 907 |
+
expect(idxAnswer).toBeGreaterThan(-1)
|
| 908 |
+
// tools 在最顶部 → reasoning 居中 → answer 在底部
|
| 909 |
+
expect(idxTools).toBeLessThan(idxReasoning)
|
| 910 |
+
expect(idxReasoning).toBeLessThan(idxAnswer)
|
| 911 |
+
})
|
| 912 |
+
|
| 913 |
+
it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
|
| 914 |
+
let resolveCreate: (() => void) | null = null
|
| 915 |
+
const createLatch = new Promise<void>((r) => { resolveCreate = r })
|
| 916 |
+
|
| 917 |
+
const { client, calls } = makeMockClient({
|
| 918 |
+
'card.create': async () => {
|
| 919 |
+
await createLatch
|
| 920 |
+
return { code: 0, data: { card_id: 'ck_slow' } }
|
| 921 |
+
},
|
| 922 |
+
'im.message.create': { data: { message_id: 'om' } },
|
| 923 |
+
})
|
| 924 |
+
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
| 925 |
+
|
| 926 |
+
// 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
|
| 927 |
+
const creating = sc.ensureCreated()
|
| 928 |
+
// 让事件循环推进到 create 被 await
|
| 929 |
+
await sleep(10)
|
| 930 |
+
sc.startTool('tu_1', 'Glob')
|
| 931 |
+
|
| 932 |
+
// 此时 cardMessageReady 仍是 false —— 没有任何 flush
|
| 933 |
+
const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 934 |
+
expect(contentBefore.length).toBe(0)
|
| 935 |
+
|
| 936 |
+
// 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
|
| 937 |
+
resolveCreate!()
|
| 938 |
+
await creating
|
| 939 |
+
await sleep(150)
|
| 940 |
+
|
| 941 |
+
const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
| 942 |
+
expect(contentAfter.length).toBeGreaterThan(0)
|
| 943 |
+
const last = contentAfter[contentAfter.length - 1]!
|
| 944 |
+
expect(last.args.data.content).toContain('Glob')
|
| 945 |
+
expect(last.args.data.content).toContain('🛠️')
|
| 946 |
+
})
|
| 947 |
+
})
|
adapters/feishu/card-errors.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Feishu CardKit API 错误码解析与谓词
|
| 3 |
+
*
|
| 4 |
+
* 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
|
| 5 |
+
*
|
| 6 |
+
* Lark SDK 抛出的错误对象结构有多种:
|
| 7 |
+
* - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
|
| 8 |
+
* - Axios 风格: error.response.data.{code, msg}
|
| 9 |
+
* - data.code 嵌套(某些包装层)
|
| 10 |
+
*
|
| 11 |
+
* 此模块把这些统一成 { code, subCode, errMsg } 结构,
|
| 12 |
+
* 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
// Error code constants
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
/** 卡片 API 级别错误码。 */
|
| 20 |
+
export const CARD_ERROR = {
|
| 21 |
+
/** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
|
| 22 |
+
RATE_LIMITED: 230020,
|
| 23 |
+
/** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
|
| 24 |
+
CARD_CONTENT_FAILED: 230099,
|
| 25 |
+
} as const
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
|
| 29 |
+
* 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
|
| 30 |
+
*/
|
| 31 |
+
export const CARD_CONTENT_SUB_ERROR = {
|
| 32 |
+
/** 卡片元素(表格等)数量超限 */
|
| 33 |
+
ELEMENT_LIMIT: 11310,
|
| 34 |
+
} as const
|
| 35 |
+
|
| 36 |
+
// ---------------------------------------------------------------------------
|
| 37 |
+
// Code extraction
|
| 38 |
+
// ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
function coerceCode(value: unknown): number | undefined {
|
| 41 |
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
| 42 |
+
if (typeof value === 'string') {
|
| 43 |
+
const parsed = Number(value)
|
| 44 |
+
if (Number.isFinite(parsed)) return parsed
|
| 45 |
+
}
|
| 46 |
+
return undefined
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
|
| 51 |
+
* - `{ code }` (SDK 直接挂载)
|
| 52 |
+
* - `{ data: { code } }` (响应体嵌套)
|
| 53 |
+
* - `{ response: { data: { code } } }` (Axios 风格)
|
| 54 |
+
*/
|
| 55 |
+
export function extractLarkApiCode(err: unknown): number | undefined {
|
| 56 |
+
if (!err || typeof err !== 'object') return undefined
|
| 57 |
+
const e = err as {
|
| 58 |
+
code?: unknown
|
| 59 |
+
data?: { code?: unknown }
|
| 60 |
+
response?: { data?: { code?: unknown } }
|
| 61 |
+
}
|
| 62 |
+
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// ---------------------------------------------------------------------------
|
| 66 |
+
// Sub-error extraction
|
| 67 |
+
// ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
|
| 71 |
+
*
|
| 72 |
+
* 示例输入:
|
| 73 |
+
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
|
| 74 |
+
* 返回: 11310
|
| 75 |
+
*/
|
| 76 |
+
export function extractSubCode(msg: string): number | null {
|
| 77 |
+
const match = /ErrCode:\s*(\d+)/.exec(msg)
|
| 78 |
+
if (!match) return null
|
| 79 |
+
const code = Number(match[1])
|
| 80 |
+
return Number.isFinite(code) ? code : null
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// ---------------------------------------------------------------------------
|
| 84 |
+
// Structured error parsing
|
| 85 |
+
// ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
export type CardApiErrorInfo = {
|
| 88 |
+
code: number
|
| 89 |
+
subCode: number | null
|
| 90 |
+
errMsg: string
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 从任意抛错对象中解析卡片 API 错误结构。
|
| 95 |
+
*
|
| 96 |
+
* 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
|
| 97 |
+
*/
|
| 98 |
+
export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
|
| 99 |
+
const code = extractLarkApiCode(err)
|
| 100 |
+
if (code === undefined) return null
|
| 101 |
+
|
| 102 |
+
// 按优先级提取 msg 文本
|
| 103 |
+
let errMsg = ''
|
| 104 |
+
if (err && typeof err === 'object') {
|
| 105 |
+
const e = err as {
|
| 106 |
+
msg?: unknown
|
| 107 |
+
message?: unknown
|
| 108 |
+
response?: { data?: { msg?: unknown } }
|
| 109 |
+
}
|
| 110 |
+
if (typeof e.msg === 'string') {
|
| 111 |
+
errMsg = e.msg
|
| 112 |
+
} else if (typeof e.response?.data?.msg === 'string') {
|
| 113 |
+
errMsg = e.response.data.msg
|
| 114 |
+
} else if (typeof e.message === 'string') {
|
| 115 |
+
errMsg = e.message
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const subCode = extractSubCode(errMsg)
|
| 120 |
+
return { code, subCode, errMsg }
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// ---------------------------------------------------------------------------
|
| 124 |
+
// Helper predicates
|
| 125 |
+
// ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
/** 判断错误是否为卡片发送频率限制(230020)。 */
|
| 128 |
+
export function isCardRateLimitError(err: unknown): boolean {
|
| 129 |
+
const parsed = parseCardApiError(err)
|
| 130 |
+
if (!parsed) return false
|
| 131 |
+
return parsed.code === CARD_ERROR.RATE_LIMITED
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* 判断错误是否为卡片表格数超限。
|
| 136 |
+
*
|
| 137 |
+
* 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
|
| 138 |
+
* (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
|
| 139 |
+
*
|
| 140 |
+
* 实际生产错误格式(openclaw-lark 2026-03 实测):
|
| 141 |
+
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
|
| 142 |
+
*/
|
| 143 |
+
export function isCardTableLimitError(err: unknown): boolean {
|
| 144 |
+
const parsed = parseCardApiError(err)
|
| 145 |
+
if (!parsed) return false
|
| 146 |
+
return (
|
| 147 |
+
parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
|
| 148 |
+
parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
|
| 149 |
+
/table number over limit/i.test(parsed.errMsg)
|
| 150 |
+
)
|
| 151 |
+
}
|
adapters/feishu/cardkit.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 飞书 CardKit API 薄封装
|
| 3 |
+
*
|
| 4 |
+
* 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。
|
| 5 |
+
*
|
| 6 |
+
* 五步流程:
|
| 7 |
+
* 1. createCardEntity() —— 创建卡片实体,返回 card_id
|
| 8 |
+
* 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
|
| 9 |
+
* 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
|
| 10 |
+
* 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
|
| 11 |
+
* 5. updateCardKitCard() —— 全量替换卡片为最终态
|
| 12 |
+
*
|
| 13 |
+
* 关键约束:
|
| 14 |
+
* - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝
|
| 15 |
+
* - streamCardContent 传的是**完整累计文本**,不是 delta
|
| 16 |
+
* - 必须关闭 streaming_mode 后卡片才能被用户交互
|
| 17 |
+
*
|
| 18 |
+
* 参考实现: openclaw-lark/src/card/cardkit.ts
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
import type * as Lark from '@larksuiteoapi/node-sdk'
|
| 22 |
+
|
| 23 |
+
// ---------------------------------------------------------------------------
|
| 24 |
+
// Constants
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
|
| 28 |
+
* `cardElement.content()` 更新的那一个 markdown 元素。 */
|
| 29 |
+
export const STREAMING_ELEMENT_ID = 'streaming_content'
|
| 30 |
+
|
| 31 |
+
// ---------------------------------------------------------------------------
|
| 32 |
+
// Types
|
| 33 |
+
// ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* SDK 返回的通用响应结构。
|
| 37 |
+
* SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
|
| 38 |
+
* 我们统一当成 CardKitResponse 处理以免到处 `as any`。
|
| 39 |
+
*/
|
| 40 |
+
type CardKitResponse = {
|
| 41 |
+
code?: number
|
| 42 |
+
msg?: string
|
| 43 |
+
data?: Record<string, unknown>
|
| 44 |
+
[key: string]: unknown
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
|
| 48 |
+
* 可被 card-errors.ts 的 parseCardApiError 识别。 */
|
| 49 |
+
export class CardKitApiError extends Error {
|
| 50 |
+
readonly code: number
|
| 51 |
+
readonly msg: string
|
| 52 |
+
|
| 53 |
+
constructor(params: { api: string; code: number; msg: string; context: string }) {
|
| 54 |
+
const { api, code, msg, context } = params
|
| 55 |
+
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
|
| 56 |
+
this.name = 'CardKitApiError'
|
| 57 |
+
this.code = code
|
| 58 |
+
this.msg = msg
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
type LarkClient = Lark.Client
|
| 63 |
+
|
| 64 |
+
const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
|
| 65 |
+
|
| 66 |
+
function getImCardRequestTimeoutMs(): number {
|
| 67 |
+
const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
| 68 |
+
const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
| 69 |
+
return Number.isFinite(parsed) && parsed > 0
|
| 70 |
+
? parsed
|
| 71 |
+
: DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export async function withImCardRequestTimeout<T>(
|
| 75 |
+
api: string,
|
| 76 |
+
request: () => Promise<T>,
|
| 77 |
+
): Promise<T> {
|
| 78 |
+
const timeoutMs = getImCardRequestTimeoutMs()
|
| 79 |
+
let timer: ReturnType<typeof setTimeout> | undefined
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
return await Promise.race([
|
| 83 |
+
Promise.resolve().then(request),
|
| 84 |
+
new Promise<never>((_, reject) => {
|
| 85 |
+
timer = setTimeout(() => {
|
| 86 |
+
reject(new Error(`${api} timed out after ${timeoutMs}ms`))
|
| 87 |
+
}, timeoutMs)
|
| 88 |
+
}),
|
| 89 |
+
])
|
| 90 |
+
} finally {
|
| 91 |
+
if (timer) clearTimeout(timer)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---------------------------------------------------------------------------
|
| 96 |
+
// Response check
|
| 97 |
+
// ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
|
| 101 |
+
*
|
| 102 |
+
* Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
|
| 103 |
+
* 判断是速率限制还是真错误。
|
| 104 |
+
*/
|
| 105 |
+
function assertCardKitOk(params: {
|
| 106 |
+
resp: CardKitResponse
|
| 107 |
+
api: string
|
| 108 |
+
context: string
|
| 109 |
+
}): void {
|
| 110 |
+
const { resp, api, context } = params
|
| 111 |
+
const code = resp.code
|
| 112 |
+
if (code !== undefined && code !== 0) {
|
| 113 |
+
throw new CardKitApiError({
|
| 114 |
+
api,
|
| 115 |
+
code,
|
| 116 |
+
msg: typeof resp.msg === 'string' ? resp.msg : '',
|
| 117 |
+
context,
|
| 118 |
+
})
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// ---------------------------------------------------------------------------
|
| 123 |
+
// Step 1 — createCardEntity
|
| 124 |
+
// ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* 创建一张 CardKit 卡片实体,返回 card_id。
|
| 128 |
+
*
|
| 129 |
+
* 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
|
| 130 |
+
*
|
| 131 |
+
* @param client Lark SDK client
|
| 132 |
+
* @param card Schema 2.0 格式的卡片 JSON
|
| 133 |
+
* @returns 飞书分配的 card_id(失败时抛错)
|
| 134 |
+
*/
|
| 135 |
+
export async function createCardEntity(
|
| 136 |
+
client: LarkClient,
|
| 137 |
+
card: Record<string, unknown>,
|
| 138 |
+
): Promise<string> {
|
| 139 |
+
// SDK 返回类型不完整,cast 到运行时实际结构
|
| 140 |
+
const resp = (await withImCardRequestTimeout('card.create', () =>
|
| 141 |
+
client.cardkit.v1.card.create({
|
| 142 |
+
data: {
|
| 143 |
+
type: 'card_json',
|
| 144 |
+
data: JSON.stringify(card),
|
| 145 |
+
},
|
| 146 |
+
}),
|
| 147 |
+
)) as unknown as CardKitResponse
|
| 148 |
+
|
| 149 |
+
assertCardKitOk({
|
| 150 |
+
resp,
|
| 151 |
+
api: 'card.create',
|
| 152 |
+
context: `cardLen=${JSON.stringify(card).length}`,
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
// 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id
|
| 156 |
+
const cardId =
|
| 157 |
+
(resp.data?.card_id as string | undefined) ??
|
| 158 |
+
(resp.card_id as string | undefined)
|
| 159 |
+
|
| 160 |
+
if (!cardId) {
|
| 161 |
+
throw new CardKitApiError({
|
| 162 |
+
api: 'card.create',
|
| 163 |
+
code: resp.code ?? -1,
|
| 164 |
+
msg: 'response missing card_id',
|
| 165 |
+
context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
|
| 166 |
+
})
|
| 167 |
+
}
|
| 168 |
+
return cardId
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// ---------------------------------------------------------------------------
|
| 172 |
+
// Step 2 — sendCardAsMessage
|
| 173 |
+
// ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* 把 CardKit 卡片通过 IM 消息挂到聊天窗。
|
| 177 |
+
*
|
| 178 |
+
* content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
|
| 179 |
+
* msg_type 固定为 `interactive`。
|
| 180 |
+
*
|
| 181 |
+
* @param client Lark SDK client
|
| 182 |
+
* @param chatId 目标 chat_id
|
| 183 |
+
* @param cardId CardKit card_id(由 createCardEntity 产生)
|
| 184 |
+
* @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create
|
| 185 |
+
* @returns 飞书分配的 message_id
|
| 186 |
+
*/
|
| 187 |
+
export async function sendCardAsMessage(
|
| 188 |
+
client: LarkClient,
|
| 189 |
+
chatId: string,
|
| 190 |
+
cardId: string,
|
| 191 |
+
replyToMessageId?: string,
|
| 192 |
+
): Promise<string> {
|
| 193 |
+
const content = JSON.stringify({
|
| 194 |
+
type: 'card',
|
| 195 |
+
data: { card_id: cardId },
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
if (replyToMessageId) {
|
| 199 |
+
const resp = await withImCardRequestTimeout('im.message.reply', () =>
|
| 200 |
+
client.im.message.reply({
|
| 201 |
+
path: { message_id: replyToMessageId },
|
| 202 |
+
data: { content, msg_type: 'interactive' },
|
| 203 |
+
}),
|
| 204 |
+
)
|
| 205 |
+
const messageId = resp.data?.message_id
|
| 206 |
+
if (!messageId) {
|
| 207 |
+
throw new CardKitApiError({
|
| 208 |
+
api: 'im.message.reply',
|
| 209 |
+
code: -1,
|
| 210 |
+
msg: 'response missing message_id',
|
| 211 |
+
context: `cardId=${cardId}`,
|
| 212 |
+
})
|
| 213 |
+
}
|
| 214 |
+
return messageId
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const resp = await withImCardRequestTimeout('im.message.create', () =>
|
| 218 |
+
client.im.message.create({
|
| 219 |
+
params: { receive_id_type: 'chat_id' },
|
| 220 |
+
data: {
|
| 221 |
+
receive_id: chatId,
|
| 222 |
+
msg_type: 'interactive',
|
| 223 |
+
content,
|
| 224 |
+
},
|
| 225 |
+
}),
|
| 226 |
+
)
|
| 227 |
+
const messageId = resp.data?.message_id
|
| 228 |
+
if (!messageId) {
|
| 229 |
+
throw new CardKitApiError({
|
| 230 |
+
api: 'im.message.create',
|
| 231 |
+
code: -1,
|
| 232 |
+
msg: 'response missing message_id',
|
| 233 |
+
context: `chatId=${chatId} cardId=${cardId}`,
|
| 234 |
+
})
|
| 235 |
+
}
|
| 236 |
+
return messageId
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// ---------------------------------------------------------------------------
|
| 240 |
+
// Step 3 — streamCardContent
|
| 241 |
+
// ---------------------------------------------------------------------------
|
| 242 |
+
|
| 243 |
+
/**
|
| 244 |
+
* 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端
|
| 245 |
+
* 渲染打字机效果。
|
| 246 |
+
*
|
| 247 |
+
* **重要**: `content` 必须传**完整累计文本**,不是 delta。
|
| 248 |
+
* sequence 必须**单调递增**,否则飞书拒绝。
|
| 249 |
+
*
|
| 250 |
+
* @param client Lark SDK client
|
| 251 |
+
* @param cardId CardKit card_id
|
| 252 |
+
* @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID)
|
| 253 |
+
* @param content 完整累计文本
|
| 254 |
+
* @param sequence 单调递增序列号
|
| 255 |
+
*/
|
| 256 |
+
export async function streamCardContent(
|
| 257 |
+
client: LarkClient,
|
| 258 |
+
cardId: string,
|
| 259 |
+
elementId: string,
|
| 260 |
+
content: string,
|
| 261 |
+
sequence: number,
|
| 262 |
+
): Promise<void> {
|
| 263 |
+
const resp = (await withImCardRequestTimeout('cardElement.content', () =>
|
| 264 |
+
client.cardkit.v1.cardElement.content({
|
| 265 |
+
data: { content, sequence },
|
| 266 |
+
path: { card_id: cardId, element_id: elementId },
|
| 267 |
+
}),
|
| 268 |
+
)) as unknown as CardKitResponse
|
| 269 |
+
|
| 270 |
+
assertCardKitOk({
|
| 271 |
+
resp,
|
| 272 |
+
api: 'cardElement.content',
|
| 273 |
+
context: `seq=${sequence} len=${content.length}`,
|
| 274 |
+
})
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// ---------------------------------------------------------------------------
|
| 278 |
+
// Step 4 — setCardStreamingMode
|
| 279 |
+
// ---------------------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`,
|
| 283 |
+
* 否则卡片会保持"只读"状态,用户点按钮没反应。
|
| 284 |
+
*/
|
| 285 |
+
export async function setCardStreamingMode(
|
| 286 |
+
client: LarkClient,
|
| 287 |
+
cardId: string,
|
| 288 |
+
streamingMode: boolean,
|
| 289 |
+
sequence: number,
|
| 290 |
+
): Promise<void> {
|
| 291 |
+
const resp = (await withImCardRequestTimeout('card.settings', () =>
|
| 292 |
+
client.cardkit.v1.card.settings({
|
| 293 |
+
data: {
|
| 294 |
+
settings: JSON.stringify({ streaming_mode: streamingMode }),
|
| 295 |
+
sequence,
|
| 296 |
+
},
|
| 297 |
+
path: { card_id: cardId },
|
| 298 |
+
}),
|
| 299 |
+
)) as unknown as CardKitResponse
|
| 300 |
+
|
| 301 |
+
assertCardKitOk({
|
| 302 |
+
resp,
|
| 303 |
+
api: 'card.settings',
|
| 304 |
+
context: `seq=${sequence} streaming_mode=${streamingMode}`,
|
| 305 |
+
})
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ---------------------------------------------------------------------------
|
| 309 |
+
// Step 5 — updateCardKitCard
|
| 310 |
+
// ---------------------------------------------------------------------------
|
| 311 |
+
|
| 312 |
+
/**
|
| 313 |
+
* 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
|
| 314 |
+
* (加 header template、footer、完成样式等)。
|
| 315 |
+
*/
|
| 316 |
+
export async function updateCardKitCard(
|
| 317 |
+
client: LarkClient,
|
| 318 |
+
cardId: string,
|
| 319 |
+
card: Record<string, unknown>,
|
| 320 |
+
sequence: number,
|
| 321 |
+
): Promise<void> {
|
| 322 |
+
const resp = (await withImCardRequestTimeout('card.update', () =>
|
| 323 |
+
client.cardkit.v1.card.update({
|
| 324 |
+
data: {
|
| 325 |
+
card: { type: 'card_json', data: JSON.stringify(card) },
|
| 326 |
+
sequence,
|
| 327 |
+
},
|
| 328 |
+
path: { card_id: cardId },
|
| 329 |
+
}),
|
| 330 |
+
)) as unknown as CardKitResponse
|
| 331 |
+
|
| 332 |
+
assertCardKitOk({
|
| 333 |
+
resp,
|
| 334 |
+
api: 'card.update',
|
| 335 |
+
context: `seq=${sequence} cardId=${cardId}`,
|
| 336 |
+
})
|
| 337 |
+
}
|