docs: Agnet 可视化分工与 M5/愿景对齐;README 更新;同步 _all-in-one-upload

Made-with: Cursor
This commit is contained in:
gongzhiyong
2026-04-30 11:06:58 +08:00
parent abbbf7210c
commit 0dd1976519
4395 changed files with 1010171 additions and 103 deletions
+4 -7
View File
@@ -10,7 +10,7 @@ Heicode 面向**多人协作、可追溯交付**的软件团队:把需求对
|------|----------|
| `cc-haha/` | **Heicode**(终端与桌面客户端及本地服务;此为源码目录名)。 |
| `new-api/` | **Heicode Manager**(网关与管理控制台服务端;此为源码目录名)。 |
| `website/` | 产品介绍的静态页面(可本地 Docker 预览)。 |
| `website/` | 产品介绍站点(Next.js;可 `pnpm dev` 或 Docker 预览)。 |
| `docs/` | 愿景与范式;**[`docs/milestones/`](./docs/milestones/README.md)** 交付里程碑;**[`docs/integration/`](./docs/integration/README.md)** Agnet 等平台接口设计。 |
| `_all-in-one-upload/` | 历史或打包目录(按需维护)。 |
@@ -52,16 +52,13 @@ HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
平台侧能力还包括模型发现(如 `GET /v1/models`)与 Anthropic Messages 兼容入口等,详见 **Heicode Manager**(`new-api/`)与 **Heicode 客户端**(`cc-haha/`)各自 README。
## 官网静态站(本地 Docker)
## 官网(Next.js)
```bash
cd /path/to/heicode
docker compose up --build
cd website && pnpm install && pnpm dev
```
浏览器访问:**http://localhost:8888**
若拉取 `nginx:alpine` 超时,请配置 Docker 镜像加速;也可直接打开 `website/index.html` 离线浏览(部分字体依赖 Google Fonts)。
若根目录提供 `docker compose`,可按 compose 说明构建预览镜像(以仓库内 `docker-compose.yml` 为准)。
## 发版与回归建议
+76
View File
@@ -0,0 +1,76 @@
# HeiCode Workspace
一个仓库,交付两套能力:
- `cc-haha`:HeiCode 客户端(CLI + Desktop)
- `new-api`:TaijiAICloud 网关(含 HeiCode 浏览器登录适配)
这个仓库面向“开箱即用的 Claude Code 体验”产品化,不要求用户手动做复杂配置。
## 为什么是单仓库
- 客户端与平台适配同步迭代,避免版本错位
- 登录、模型发现、默认模型策略在同一个版本闭环验证
- 交付给客户时只有一个代码入口,部署和排障路径更短
## 仓库结构
```text
.
├── cc-haha/ # HeiCode 客户端主工程
├── new-api/ # TaijiAICloud 网关工程(已接入 HeiCode OAuth)
├── package.json
└── bun.lock
```
## 一分钟启动
```bash
# 1) 安装根依赖
bun install
# 2) 启动 HeiCode 本地服务
cd cc-haha
bun run src/server/index.ts
# 3) 新开终端启动桌面端
cd cc-haha/desktop
bun run tauri dev
```
若本地联调网关地址:
```bash
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
```
## 当前产品特性
- 品牌统一为 `HeiCode`
- 登录入口聚焦平台登录流程(浏览器 OAuth 优先)
- 桌面端已移除 Providers 菜单与 GitHub 外链元素
- 模型选择改为按 Provider API 动态拉全量模型
- 支持 `taijiaicloud` 与 `clawdrouter` 双平台预设
## 平台侧接口约定(最小集)
- `GET /v1/models`:模型列表发现
- Anthropic Messages 兼容入口:供 HeiCode 直接调用
- OAuth 风格授权入口(推荐):支持桌面端回调到本地 loopback
`new-api` 已实现 HeiCode 登录相关路由:
- `GET /heicode/oauth/authorize`
- `GET /heicode/oauth/session`
## 文档导航
- 客户端说明:`cc-haha/README.md`
- 网关说明:`new-api/README.md`
## 交付建议
- 用同一版本标签同时发布客户端与网关镜像
- 每次发版至少回归三条链路:登录、模型拉取、对话请求
- 先在本地 docker 联调通过,再做外网域名与证书配置
+131
View File
@@ -0,0 +1,131 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.62.1",
"grammy": "^1.42.0",
},
},
},
"packages": {
"@grammyjs/types": ["@grammyjs/types@3.26.0", "", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.62.1", "", { "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-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "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=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "", { "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=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "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=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"grammy": ["grammy@1.42.0", "", { "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=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"lodash.identity": ["lodash.identity@3.0.0", "", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.pickby": ["lodash.pickby@4.6.0", "", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"protobufjs": ["protobufjs@7.5.6", "", { "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=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"side-channel": ["side-channel@1.1.0", "", { "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=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "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=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
}
}
+37
View File
@@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
The root package is the Bun-based CLI and local server. Main code lives in `src/`: `entrypoints/` for startup paths, `screens/` and `components/` for the Ink TUI, `commands/` for slash commands, `services/` for API/MCP/OAuth logic, and `tools/` for agent tool implementations. `bin/claude-haha` is the executable entrypoint. The desktop app is isolated in `desktop/` with React UI code in `desktop/src/` and Tauri glue in `desktop/src-tauri/`. Documentation is in `docs/` and builds with VitePress. Treat root screenshots and `docs/images/` as reference assets, not source code.
## Build, Test, and Development Commands
Install root dependencies with `bun install`, then install desktop dependencies in `desktop/` if you are touching the app UI.
- `./bin/claude-haha` or `bun run start`: run the CLI locally.
- `SERVER_PORT=3456 bun run src/server/index.ts`: start the local API/WebSocket server used by `desktop/`.
- `bun run docs:dev` / `bun run docs:build`: preview or build the VitePress docs.
- `cd desktop && bun run dev`: run the desktop frontend in Vite.
- `cd desktop && bun run build`: type-check and produce a production web build.
- `cd desktop && bun run test`: run Vitest suites.
- `cd desktop && bun run lint`: run TypeScript no-emit checks.
## Desktop Release Workflow
- Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts.
- The release workflow is `.github/workflows/release-desktop.yml`; it triggers automatically on `push` of tags matching `v*.*.*`.
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename aligned with the version/tag exactly.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates version files, refreshes `desktop/src-tauri/Cargo.lock`, requires the matching `release-notes/vX.Y.Z.md`, commits it, and creates the annotated tag.
- The normal release push is `git push origin main --tags`. If the tag, app version, or release-notes filename do not match, the workflow is designed to fail fast instead of publishing the wrong release.
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, and outputs land under `desktop/build-artifacts/macos-arm64/`.
## Docs Workflow Notes
- The docs workflow is `.github/workflows/deploy-docs.yml` and uses `npm ci`, not Bun. When root `package.json` dependencies change, keep `package-lock.json` in the same commit or the docs build will fail.
- The docs workflow currently runs on Node 22; avoid reintroducing older Node assumptions there without checking dependency engine requirements.
## Coding Style & Naming Conventions
Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match the existing code. Prefer `PascalCase` for React components, `camelCase` for functions, hooks, and stores, and descriptive file names like `teamWatcher.ts` or `AgentTranscript.tsx`. Keep shared UI in `desktop/src/components/`, API clients in `desktop/src/api/`, and avoid adding new dependencies unless the existing utilities cannot cover the change.
## Testing Guidelines
Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR.
## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep subjects imperative and scoped to one change. PRs should explain the user-visible impact, list verification steps, link related issues, and include screenshots for desktop or docs UI changes. Keep diffs reviewable and call out any follow-up work or known gaps.
Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository.
+59
View File
@@ -0,0 +1,59 @@
# HeiCode Server — local API/WebSocket server in a container.
#
# NOTE:
# We intentionally do NOT use `FROM oven/bun:*` here because some environments
# have intermittent Docker Hub TLS proxy issues when pulling that image.
# Instead we use alpine (already commonly cached) and download Bun directly
# from GitHub release assets during build.
#
# Build: docker build -t heicode-server -f Dockerfile .
# Run: see docker-compose.yml at the repo root.
FROM alpine:latest
# Bun on Alpine still needs libstdc++/libgcc at runtime.
# Network to Alpine mirrors can be flaky in this environment, so retry.
RUN set -eux; \
for i in 1 2 3 4 5 6 7 8; do \
apk add --no-cache libstdc++ libgcc && break; \
echo "apk add failed (attempt ${i}), retrying..."; \
sleep 3; \
done
# Install Bun (musl build for Alpine on arm64) from a local build asset.
# This avoids flaky outbound TLS during `docker build` in restricted networks.
COPY .docker-assets/bun-linux-aarch64-musl.zip /tmp/bun.zip
RUN busybox unzip -q /tmp/bun.zip -d /tmp \
&& mv /tmp/bun-linux-aarch64-musl/bun /usr/local/bin/bun \
&& chmod +x /usr/local/bin/bun \
&& rm -rf /tmp/bun.zip /tmp/bun-linux-aarch64-musl
WORKDIR /app
# Install dependencies in a separate layer to maximise cache hits across
# code-only changes.
COPY package.json bun.lock bunfig.toml preload.ts ./
RUN bun install --frozen-lockfile
# Copy the rest of the source. We deliberately *don't* copy the desktop/
# Tauri tree because the server doesn't need it; it dramatically speeds up
# the build and keeps the image lean. The `desktop/` folder lives in the
# repo only as a sibling project.
COPY tsconfig.json ./
COPY src ./src
COPY adapters ./adapters
COPY runtime ./runtime
COPY stubs ./stubs
COPY fixtures ./fixtures
COPY bin ./bin
# Default port the server listens on.
ENV SERVER_PORT=3456
ENV SERVER_HOST=0.0.0.0
# Bind to /data so cc-haha's ~/.claude config persists across container restarts.
ENV CLAUDE_CONFIG_DIR=/data/.claude
RUN mkdir -p /data/.claude
EXPOSE 3456
CMD ["bun", "run", "src/server/index.ts"]
+38
View File
@@ -0,0 +1,38 @@
DISCLAIMER AND LICENSE
Copyright (c) 2026 Anthropic, PBC. All rights reserved for the original source code.
This repository is based on the Claude Code source code leaked from the Anthropic
npm registry on 2026-03-31. All original source code copyrights belong to Anthropic.
THIS SOFTWARE IS PROVIDED FOR EDUCATIONAL AND RESEARCH PURPOSES ONLY.
The purpose of this repository is to facilitate the study and research of AI Agent
engineering, including but not limited to:
- Agent architecture and design patterns
- Tool use and function calling mechanisms
- Multi-agent orchestration
- Terminal UI (TUI) implementation for AI applications
- MCP (Model Context Protocol) integration
RESTRICTIONS:
1. This software SHALL NOT be used for any commercial purposes.
2. This software SHALL NOT be used to compete with or replicate Anthropic's
commercial products or services.
3. This software SHALL NOT be redistributed, sublicensed, or sold in any form.
4. Users SHALL comply with all applicable laws and regulations in their jurisdiction.
NO WARRANTY:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
By using this software, you acknowledge that:
- You understand this is leaked source code and all original rights belong to Anthropic.
- You will use it solely for learning and research of Agent engineering.
- You assume all legal risks and responsibilities arising from the use of this software.
+302
View File
@@ -0,0 +1,302 @@
# Claude Code Haha
<p align="center">
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
</p>
<div align="center">
[![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/issues)
[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/pulls)
[![License](https://img.shields.io/github/license/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/blob/main/LICENSE)
[![中文](https://img.shields.io/badge/🇨🇳_中文-Available-green)](README.md)
[![English](https://img.shields.io/badge/🇺🇸_English-当前-blue)](README.en.md)
[![Docs](https://img.shields.io/badge/📖_Documentation-Visit-D97757)](https://claudecode-haha.relakkesyang.org)
</div>
A **locally runnable version** repaired from the leaked Claude Code source, with support for any Anthropic-compatible API endpoint (MiniMax, OpenRouter, etc.). Beyond the full TUI, we've also completed Computer Use (macOS / Windows), built a GUI **desktop app**, and enabled **full remote control** via Telegram / Feishu.
<p align="center">
<a href="#features">Features</a> · <a href="#desktop-preview">Desktop</a> · <a href="#architecture-overview">Architecture</a> · <a href="#quick-start">Quick Start</a> · <a href="docs/en/guide/env-vars.md">Env Vars</a> · <a href="docs/en/guide/faq.md">FAQ</a> · <a href="docs/en/guide/global-usage.md">Global Usage</a> · <a href="#more-documentation">More Docs</a>
</p>
---
## Features
- Full Ink TUI experience (matching the official Claude Code interface)
- `--print` headless mode for scripts and CI
- MCP server, plugin, and Skills support
- Custom API endpoint and model support ([Third-Party Models Guide](docs/en/guide/third-party-models.md))
- **Memory System** (cross-session persistent memory) — [Usage Guide](docs/memory/01-usage-guide.md)
- **Multi-Agent System** (agent orchestration, parallel tasks, Teams collaboration) — [Usage Guide](docs/agent/01-usage-guide.md) | [Implementation](docs/agent/02-implementation.md)
- **Skills System** (extensible capability plugins, custom workflows) — [Usage Guide](docs/skills/01-usage-guide.md) | [Implementation](docs/skills/02-implementation.md)
- **Channel System** (remote Agent control via Telegram/Feishu/Discord IM platforms) — [Architecture](docs/en/channel/01-channel-system.md)
- **Computer Use desktop control** — [Guide](docs/en/features/computer-use.md) | [Architecture](docs/en/features/computer-use-architecture.md)
- **Desktop App** (Tauri 2 + React GUI client, multi-tab multi-session) — [Docs](docs/desktop/)
- Fallback Recovery CLI mode (`CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha`)
---
## Architecture Overview
<table>
<tr>
<td align="center" width="25%"><img src="docs/images/01-overall-architecture.png" alt="Overall architecture"><br><b>Overall architecture</b></td>
<td align="center" width="25%"><img src="docs/images/02-request-lifecycle.png" alt="Request lifecycle"><br><b>Request lifecycle</b></td>
<td align="center" width="25%"><img src="docs/images/03-tool-system.png" alt="Tool system"><br><b>Tool system</b></td>
<td align="center" width="25%"><img src="docs/images/04-multi-agent.png" alt="Multi-agent architecture"><br><b>Multi-agent architecture</b></td>
</tr>
<tr>
<td align="center" width="25%"><img src="docs/images/05-terminal-ui.png" alt="Terminal UI"><br><b>Terminal UI</b></td>
<td align="center" width="25%"><img src="docs/images/06-permission-security.png" alt="Permissions and security"><br><b>Permissions and security</b></td>
<td align="center" width="25%"><img src="docs/images/07-services-layer.png" alt="Services layer"><br><b>Services layer</b></td>
<td align="center" width="25%"><img src="docs/images/08-state-data-flow.png" alt="State and data flow"><br><b>State and data flow</b></td>
</tr>
</table>
---
## Desktop Preview
<p align="center">
<a href="https://github.com/NanmiCoder/cc-haha/releases"><img src="https://img.shields.io/badge/⬇_Download_Desktop-macOS_%7C_Windows-D97757?style=for-the-badge" alt="Download Desktop"></a>
&nbsp;
<a href="docs/desktop/04-installation.md"><img src="https://img.shields.io/badge/📖_Install_Guide-Guide-gray?style=for-the-badge" alt="Install Guide"></a>
</p>
<table>
<tr>
<td align="center" width="33%"><img src="docs/images/desktop_ui/01_full_ui.png" alt="Main UI"><br><b>Main Interface</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/02_edit_code.png" alt="Code Editing"><br><b>Code Editing & Diff View</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/03_ask_question_and_permission.png" alt="Permission Control"><br><b>Permission Control & AI Questions</b></td>
</tr>
<tr>
<td align="center" width="33%"><img src="docs/images/desktop_ui/05_settings.png" alt="Provider Settings"><br><b>Multi-Provider Management</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/08_scheduled_task.png" alt="Scheduled Tasks"><br><b>Scheduled Tasks</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/07_im.png" alt="IM Adapters"><br><b>IM Adapters (Telegram / Feishu)</b></td>
</tr>
</table>
---
## Quick Start
### 1. Install Bun
```bash
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# macOS (Homebrew)
brew install bun
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
> On minimal Linux images, if you see `unzip is required`, run `apt update && apt install -y unzip` first.
### 2. Install Dependencies and Configure
```bash
bun install
cp .env.example .env
# Edit .env with your API key — see docs/en/guide/env-vars.md for details
```
### 3. Start
#### macOS / Linux
```bash
./bin/claude-haha # Interactive TUI mode
./bin/claude-haha -p "your prompt here" # Headless mode
./bin/claude-haha --help # Show all options
```
#### Windows
> **Prerequisite**: [Git for Windows](https://git-scm.com/download/win) must be installed.
```powershell
# PowerShell / cmd — call Bun directly
bun --env-file=.env ./src/entrypoints/cli.tsx
# Or run inside Git Bash
./bin/claude-haha
```
### 4. Global Usage (Optional)
Add `bin/` to your PATH to run from any directory. See [Global Usage Guide](docs/en/guide/global-usage.md):
```bash
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
```
### 5. Desktop Development
If you are developing or testing the `desktop/` frontend, start both the API server and the desktop frontend.
#### 5.1 Start the API server
```bash
cd /Users/nanmi/workspace/myself_code/claude-code-haha
SERVER_PORT=3456 bun run src/server/index.ts
```
Optional health check:
```bash
curl http://127.0.0.1:3456/health
```
#### 5.2 Start the desktop frontend
```bash
cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop
bun run dev --host 127.0.0.1 --port 2024
```
Then open:
```text
http://127.0.0.1:2024
```
#### 5.3 Notes
- If port `3456` is already occupied by an old server process, run `lsof -nP -iTCP:3456 -sTCP:LISTEN`, find the PID, then `kill <PID>`.
- For chat testing, create a fresh session and re-select a real working directory.
- If an old session points to a deleted directory, the server will return `Working directory does not exist`. That is separate from whether the API server is running.
---
## Tech Stack
| Category | Technology |
|------|------|
| Runtime | [Bun](https://bun.sh) |
| Language | TypeScript |
| Terminal UI | React + [Ink](https://github.com/vadimdemedes/ink) |
| CLI parsing | Commander.js |
| API | Anthropic SDK |
| Protocols | MCP, LSP |
---
## More Documentation
| Document | Description |
|------|------|
| [Environment Variables](docs/en/guide/env-vars.md) | Full env var reference and configuration methods |
| [Third-Party Models](docs/en/guide/third-party-models.md) | Using OpenAI / DeepSeek / Ollama and other non-Anthropic models |
| [Memory System](docs/memory/01-usage-guide.md) | Cross-session persistent memory usage and implementation |
| [Multi-Agent System](docs/agent/01-usage-guide.md) | Agent orchestration, parallel tasks and Teams collaboration |
| [Skills System](docs/skills/01-usage-guide.md) | Extensible capability plugins, custom workflows and conditional activation |
| [Channel System](docs/en/channel/01-channel-system.md) | Remote Agent control via Telegram/Feishu/Discord IM platforms |
| [Computer Use](docs/en/features/computer-use.md) | Desktop control (screenshots, mouse, keyboard) — [Architecture](docs/en/features/computer-use-architecture.md) |
| [Desktop App](docs/desktop/) | Tauri 2 + React GUI client — [Quick Start](docs/desktop/01-quick-start.md) \| [Architecture](docs/desktop/02-architecture.md) \| [Installation](docs/desktop/04-installation.md) |
| [Global Usage](docs/en/guide/global-usage.md) | Run claude-haha from any directory |
| [FAQ](docs/en/guide/faq.md) | Common error troubleshooting |
| [Source Fixes](docs/en/reference/fixes.md) | Fixes compared with the original leaked source |
| [Project Structure](docs/en/reference/project-structure.md) | Code directory structure |
---
## Sponsorship & Partnership
This project is maintained in the author's spare time. Corporate or individual sponsorships are welcome to support ongoing development. Custom features, integrations, and business partnerships are also open for discussion.
<table>
<thead>
<tr>
<th width="220">Sponsor</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="JieKou AI"><br>
<strong>接口AI</strong>
</a>
</td>
<td valign="middle">
Thanks to <a href="https://jiekou.ai/referral?invited_code=OBNU3K">JieKou AI</a> for sponsoring this project. JieKou AI provides official model resources with stable, high-performance API access. Subscription bundles are priced at 20% off the official rate; new users who register through <a href="https://jiekou.ai/referral?invited_code=OBNU3K">this link</a> and bind GitHub can claim a $3 coupon.
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="ShengSuanYun">
</a>
</td>
<td valign="middle">
Thanks to <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">ShengSuanYun</a> for sponsoring this project. ShengSuanYun is an industrial-grade AI task parallel execution platform for AI Native Teams, aggregating Claude, ChatGPT, Gemini, and other LLM, image, and video model capacity through direct, non-reverse-engineered access. Its platform SLA reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">service status</a> available online. It also supports dedicated enterprise gateways, cost and permission controls, smart routing, security protection, BYOK, usage-based billing, upcoming tokens plans, and invoicing. New users registering through <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">this link</a> can receive 10 yuan in model credits plus a 10% first top-up bonus.
</td>
</tr>
</tbody>
</table>
📧 **Contact**: relakkes@gmail.com
---
## ☕ Buy Me a Coffee
If this project helps you, consider buying me a coffee — every bit of support keeps this project going ❤️
<table>
<tr>
<td align="center" width="33%">
<img src="docs/images/donate/wechat_pay.jpeg" width="250" alt="WeChat Pay"><br>
<b>WeChat Pay</b>
</td>
<td align="center" width="33%">
<img src="docs/images/donate/zfb_pay.png" width="250" alt="Alipay"><br>
<b>Alipay</b>
</td>
<td align="center" width="33%">
<a href="https://buymeacoffee.com/relakkes" target="_blank">
<img src="docs/images/donate/bmc_button.png" width="250" alt="Buy Me a Coffee">
</a><br>
<b>Buy Me a Coffee</b>
</td>
</tr>
</table>
---
## Thanks
Thanks to the following open-source projects and community practices for reference and inspiration:
- [React](https://github.com/facebook/react): frontend engineering and component-based UI ecosystem.
- [Tauri](https://github.com/tauri-apps/tauri): cross-platform desktop app capabilities and engineering practices.
- [cc-switch](https://github.com/farion1231/cc-switch): reference for model provider configuration.
---
## ⭐ Star History
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## Disclaimer
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.
+73
View File
@@ -0,0 +1,73 @@
# HeiCode Client (`cc-haha`)
HeiCode 客户端工程,目标是把 “Claude Code 使用体验” 产品化,并对接企业模型平台。
## 你能得到什么
- 一个可运行的 CLI + Desktop 客户端
- 双平台登录入口(TaijiAICloud / ClawdRouter)
- 动态模型拉取,不写死模型名单
- 适配企业交付的品牌化 UI
## 核心能力
- **登录流程**:优先浏览器 OAuth,兼容平台令牌登录
- **模型发现**:通过 provider API 拉全量模型,不强绑定某一家模型
- **运行方式**:同一套 provider 配置可在 CLI 与 Desktop 复用
- **本地 sidecar**:桌面端通过本地服务层与 provider 通信,便于状态管理和扩展
## 目录速览
```text
cc-haha/
├── bin/ # 启动入口(heicode、兼容别名)
├── src/
│ ├── server/ # 本地 API/WS 服务
│ │ ├── api/heicode-auth.ts # 登录相关接口
│ │ ├── api/providers.ts # 模型拉取与 provider API
│ │ └── config/providerPresets.*
│ └── ... # CLI 逻辑
├── desktop/ # Tauri + React 桌面端
└── docs/ # 产品和技术文档
```
## 快速启动
```bash
# 安装依赖
bun install
# 启动本地服务
bun run src/server/index.ts
# 启动桌面端(新终端)
cd desktop
bun run tauri dev
```
本地联调 TaijiAICloud:
```bash
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
```
## 开发约定
- `providerPresets` 只保留产品确定的 provider
- UI 不暴露无关配置项(例如已移除 Providers 菜单)
- 模型来源以 provider 实际返回为准
- 认证相关改动优先保持“零配置”用户体验
## 已完成改造(本分支)
- 品牌统一为 `HeiCode`
- 登录页去除 API Key 输入区域及相关提示文案
- 设置页移除 Providers 菜单
- 侧边栏和 About 移除 GitHub 链接与图标
- 模型选择器按 provider API 动态拉取模型,修复无 Claude provider 仍展示 Claude 模型的问题
## 下一步建议
- 增加登录与模型发现的 E2E 冒烟测试
- 将 OAuth 流程状态可视化(处理中/失败原因/重试)
- 增加 provider 超时与降级策略(提升桌面端可用性)
@@ -0,0 +1,141 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "claude-code-im-adapters",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"grammy": "^1.42.0",
"ws": "^8.18.0",
},
"devDependencies": {
"@types/ws": "^8.5.0",
"bun-types": "latest",
},
},
},
"packages": {
"@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
"@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=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.4.tgz", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@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=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@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=="],
"@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=="],
"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=="],
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"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=="],
"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=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"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=="],
"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=="],
"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=="],
"follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"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=="],
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"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=="],
"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=="],
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"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=="],
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"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=="],
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"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=="],
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"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=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"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=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"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=="],
"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=="],
}
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'bun:test'
import { enqueue } from '../chat-queue.js'
describe('ChatQueue', () => {
it('executes tasks for the same chatId serially', async () => {
const order: number[] = []
await Promise.all([
enqueue('chat-1', async () => {
await new Promise((r) => setTimeout(r, 30))
order.push(1)
}),
enqueue('chat-1', async () => {
order.push(2)
}),
enqueue('chat-1', async () => {
order.push(3)
}),
])
// Wait for all to complete
await new Promise((r) => setTimeout(r, 50))
expect(order).toEqual([1, 2, 3])
})
it('executes tasks for different chatIds in parallel', async () => {
const order: string[] = []
const p1 = enqueue('chat-a', async () => {
await new Promise((r) => setTimeout(r, 30))
order.push('a')
})
const p2 = enqueue('chat-b', async () => {
order.push('b') // should run immediately, not wait for chat-a
})
await Promise.all([p1, p2])
await new Promise((r) => setTimeout(r, 50))
// 'b' should appear before 'a' since chat-a has a delay
expect(order[0]).toBe('b')
expect(order[1]).toBe('a')
})
it('continues processing after a task fails', async () => {
const order: number[] = []
await enqueue('chat-err', async () => {
order.push(1)
throw new Error('task failed')
})
await enqueue('chat-err', async () => {
order.push(2) // should still run
})
await new Promise((r) => setTimeout(r, 20))
expect(order).toEqual([1, 2])
})
})
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'bun:test'
import {
formatImHelp,
formatImStatus,
splitMessage,
formatToolUse,
formatPermissionRequest,
truncateInput,
escapeMarkdownV2,
} from '../format.js'
describe('splitMessage', () => {
it('returns single chunk for short text', () => {
expect(splitMessage('hello', 100)).toEqual(['hello'])
})
it('splits at paragraph boundary', () => {
const text = 'First paragraph.\n\nSecond paragraph.'
const chunks = splitMessage(text, 20)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
})
it('splits at newline if no paragraph break', () => {
const text = 'Line one\nLine two\nLine three\nLine four'
const chunks = splitMessage(text, 20)
expect(chunks.length).toBeGreaterThan(1)
})
it('hard-splits at limit if no natural break', () => {
const text = 'a'.repeat(50)
const chunks = splitMessage(text, 20)
expect(chunks.length).toBe(3) // 20 + 20 + 10
expect(chunks.every((c) => c.length <= 20)).toBe(true)
})
it('preserves all content after splitting', () => {
const text = 'Hello world. This is a test. Foo bar baz.'
const chunks = splitMessage(text, 15)
const joined = chunks.join(' ')
// All words should be present
expect(joined).toContain('Hello')
expect(joined).toContain('test')
expect(joined).toContain('baz')
})
})
describe('formatToolUse', () => {
it('includes tool name and input preview', () => {
const result = formatToolUse('Bash', { command: 'npm test' })
expect(result).toContain('🔧 Bash')
expect(result).toContain('npm test')
})
})
describe('formatPermissionRequest', () => {
it('includes tool name, input preview, and request ID', () => {
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
expect(result).toContain('🔐')
expect(result).toContain('Bash')
expect(result).toContain('abcde')
expect(result).toContain('rm -rf')
})
})
describe('truncateInput', () => {
it('returns short input as-is', () => {
expect(truncateInput('hello', 100)).toBe('hello')
})
it('truncates long input with ellipsis', () => {
const long = 'x'.repeat(300)
const result = truncateInput(long, 100)
expect(result.length).toBe(101) // 100 chars + '…'
expect(result.endsWith('…')).toBe(true)
})
it('handles objects by stringifying', () => {
const result = truncateInput({ key: 'value' }, 100)
expect(result).toContain('key')
expect(result).toContain('value')
})
it('handles unserializable input', () => {
const circular: any = {}
circular.self = circular
expect(truncateInput(circular, 100)).toBe('(unserializable)')
})
})
describe('escapeMarkdownV2', () => {
it('escapes special characters', () => {
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
})
it('leaves plain text unchanged', () => {
expect(escapeMarkdownV2('hello world')).toBe('hello world')
})
})
describe('formatImHelp', () => {
it('lists the lightweight IM commands', () => {
const text = formatImHelp()
expect(text).toContain('/new')
expect(text).toContain('/projects')
expect(text).toContain('/status')
expect(text).toContain('/clear')
expect(text).toContain('/stop')
expect(text).toContain('/help')
})
})
describe('formatImStatus', () => {
it('formats an active session summary for mobile reading', () => {
const text = formatImStatus({
sessionId: 'abc1234567890',
projectName: 'claude-code-haha',
branch: 'main',
model: 'claude-sonnet',
state: 'tool_executing',
verb: 'Running tests',
pendingPermissionCount: 1,
taskCounts: {
total: 4,
pending: 1,
inProgress: 2,
completed: 1,
},
})
expect(text).toContain('项目: claude-code-haha (main)')
expect(text).toContain('会话: abc12345…')
expect(text).toContain('模型: claude-sonnet')
expect(text).toContain('状态: 执行工具中 (Running tests)')
expect(text).toContain('审批: 1 个待确认')
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
})
it('returns a friendly empty-session message when nothing is active', () => {
const text = formatImStatus(null)
expect(text).toContain('当前没有活动会话')
expect(text).toContain('/new')
expect(text).toContain('/projects')
})
})
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import { AdapterHttpClient } from '../http-client.js'
describe('AdapterHttpClient', () => {
let client: AdapterHttpClient
const originalFetch = globalThis.fetch
beforeEach(() => {
client = new AdapterHttpClient('ws://127.0.0.1:3456')
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it('derives HTTP URL from WS URL', () => {
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
const secure = new AdapterHttpClient('wss://example.com:443')
expect(secure.httpBaseUrl).toBe('https://example.com:443')
})
it('createSession calls POST /api/sessions', async () => {
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
) as any
const sessionId = await client.createSession('/path/to/project')
expect(sessionId).toBe(mockSessionId)
const call = (globalThis.fetch as any).mock.calls[0]
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
const body = JSON.parse(call[1].body)
expect(body.workDir).toBe('/path/to/project')
})
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
const mockProjects = [
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const projects = await client.listRecentProjects()
expect(projects).toHaveLength(1)
expect(projects[0].projectName).toBe('my-app')
})
it('createSession throws on server error', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
}))
) as any
expect(client.createSession('')).rejects.toThrow()
})
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
branch: 'main',
repoName: 'claude-code-haha',
workDir: '/repo/claude-code-haha',
changedFiles: 2,
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const gitInfo = await client.getGitInfo('session-123')
expect(gitInfo.repoName).toBe('claude-code-haha')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/sessions/session-123/git-info',
)
})
it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
tasks: [
{ id: '1', subject: 'Fix bug', status: 'in_progress' },
{ id: '2', subject: 'Write docs', status: 'pending' },
],
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const tasks = await client.getTasksForSession('session-123')
expect(tasks).toHaveLength(2)
expect(tasks[0]?.status).toBe('in_progress')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/tasks/lists/session-123',
)
})
})
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach } from 'bun:test'
import { MessageBuffer } from '../message-buffer.js'
describe('MessageBuffer', () => {
it('accumulates text and flushes on complete', async () => {
const flushed: Array<{ text: string; isComplete: boolean }> = []
const buf = new MessageBuffer(
(text, isComplete) => { flushed.push({ text, isComplete }) },
500, // 500ms interval
1000, // 1000 char threshold
)
buf.append('Hello ')
buf.append('World')
await buf.complete()
expect(flushed.length).toBeGreaterThanOrEqual(1)
const allText = flushed.map((f) => f.text).join('')
expect(allText).toBe('Hello World')
// Last flush should be marked complete
expect(flushed[flushed.length - 1]!.isComplete).toBe(true)
})
it('flushes when character threshold is reached', async () => {
const flushed: string[] = []
const buf = new MessageBuffer(
(text) => { flushed.push(text) },
10000, // very long interval (won't trigger)
10, // 10 char threshold
)
buf.append('12345678901') // 11 chars > threshold
// Wait for microtask
await new Promise((r) => setTimeout(r, 10))
expect(flushed.length).toBeGreaterThanOrEqual(1)
buf.reset()
})
it('flushes on timer interval', async () => {
const flushed: string[] = []
const buf = new MessageBuffer(
(text) => { flushed.push(text) },
50, // 50ms interval
1000,
)
buf.append('hi')
// Wait for timer
await new Promise((r) => setTimeout(r, 80))
expect(flushed).toContain('hi')
buf.reset()
})
it('does not flush empty buffer on complete', async () => {
const flushed: string[] = []
const buf = new MessageBuffer(
(text) => { flushed.push(text) },
)
await buf.complete()
expect(flushed.length).toBe(0)
})
it('resets properly between messages', async () => {
const flushed: string[] = []
const buf = new MessageBuffer(
(text) => { flushed.push(text) },
500,
1000,
)
buf.append('first')
buf.reset()
buf.append('second')
await buf.complete()
const allText = flushed.map((f) => f).join('')
expect(allText).toBe('second')
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { MessageDedup } from '../message-dedup.js'
describe('MessageDedup', () => {
let dedup: MessageDedup
beforeEach(() => {
dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries
})
afterEach(() => {
dedup.destroy()
})
it('returns true for new messages', () => {
expect(dedup.tryRecord('msg-1')).toBe(true)
expect(dedup.tryRecord('msg-2')).toBe(true)
})
it('returns false for duplicate messages', () => {
expect(dedup.tryRecord('msg-1')).toBe(true)
expect(dedup.tryRecord('msg-1')).toBe(false)
expect(dedup.tryRecord('msg-1')).toBe(false)
})
it('allows same ID after TTL expires', async () => {
const shortDedup = new MessageDedup(50, 100) // 50ms TTL
expect(shortDedup.tryRecord('msg-1')).toBe(true)
expect(shortDedup.tryRecord('msg-1')).toBe(false)
await new Promise((r) => setTimeout(r, 60))
expect(shortDedup.tryRecord('msg-1')).toBe(true)
shortDedup.destroy()
})
it('evicts oldest entry when at capacity', () => {
const smallDedup = new MessageDedup(60_000, 3) // max 3 entries
expect(smallDedup.tryRecord('a')).toBe(true)
expect(smallDedup.tryRecord('b')).toBe(true)
expect(smallDedup.tryRecord('c')).toBe(true)
// Adding 4th should evict 'a'
expect(smallDedup.tryRecord('d')).toBe(true)
// 'a' was evicted, should be treated as new
expect(smallDedup.tryRecord('a')).toBe(true)
// Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted
// 'c' should still be deduped (was not evicted)
expect(smallDedup.tryRecord('c')).toBe(false)
smallDedup.destroy()
})
it('handles distinct messages independently', () => {
expect(dedup.tryRecord('msg-1')).toBe(true)
expect(dedup.tryRecord('msg-2')).toBe(true)
expect(dedup.tryRecord('msg-1')).toBe(false)
expect(dedup.tryRecord('msg-2')).toBe(false)
expect(dedup.tryRecord('msg-3')).toBe(true)
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionStore } from '../session-store.js'
describe('SessionStore', () => {
let tmpDir: string
let store: SessionStore
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
it('returns null for unknown chatId', () => {
expect(store.get('unknown')).toBeNull()
})
it('stores and retrieves a session', () => {
store.set('chat-1', 'uuid-aaa', '/path/to/project')
const entry = store.get('chat-1')
expect(entry).not.toBeNull()
expect(entry!.sessionId).toBe('uuid-aaa')
expect(entry!.workDir).toBe('/path/to/project')
})
it('overwrites existing entry on set', () => {
store.set('chat-1', 'uuid-aaa', '/old')
store.set('chat-1', 'uuid-bbb', '/new')
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
})
it('deletes an entry', () => {
store.set('chat-1', 'uuid-aaa', '/path')
store.delete('chat-1')
expect(store.get('chat-1')).toBeNull()
})
it('persists to disk and reloads', () => {
store.set('chat-1', 'uuid-aaa', '/path')
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
})
it('handles missing file gracefully', () => {
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
expect(store2.get('anything')).toBeNull()
})
it('lists all entries', () => {
store.set('chat-1', 'uuid-1', '/a')
store.set('chat-2', 'uuid-2', '/b')
const all = store.listAll()
expect(all).toHaveLength(2)
})
})
@@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { WsBridge } from '../ws-bridge.js'
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
describe('WsBridge', () => {
let bridge: WsBridge
beforeEach(() => {
bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
})
afterEach(() => {
bridge.destroy()
})
it('connectSession connects with provided sessionId', () => {
const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
expect(result).toBe(true)
expect(bridge.hasSession('chat-1')).toBe(true)
})
it('connectSession for different chatIds creates separate sessions', () => {
bridge.connectSession('chat-1', 'uuid-1')
bridge.connectSession('chat-2', 'uuid-2')
expect(bridge.hasSession('chat-1')).toBe(true)
expect(bridge.hasSession('chat-2')).toBe(true)
})
it('resetSession removes the session', () => {
bridge.connectSession('chat-reset', 'uuid-reset')
bridge.resetSession('chat-reset')
expect(bridge.hasSession('chat-reset')).toBe(false)
})
it('sendUserMessage returns false when no open connection', () => {
bridge.connectSession('chat-offline', 'uuid-offline')
expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
})
it('sendPermissionResponse returns false when no open connection', () => {
bridge.connectSession('chat-perm', 'uuid-perm')
expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
})
it('sendStopGeneration returns false when no open connection', () => {
bridge.connectSession('chat-stop', 'uuid-stop')
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
})
it('destroy cleans up all sessions', () => {
bridge.connectSession('a', 'uuid-a')
bridge.connectSession('b', 'uuid-b')
bridge.destroy()
expect(bridge.hasSession('a')).toBe(false)
expect(bridge.hasSession('b')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Integration: per-chat handler serialization
//
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
// state msg 1's continuation is about to clear.
// ---------------------------------------------------------------------------
describe('WsBridge: handler serialization', () => {
let server: WebSocketServer
let port: number
let connections: WsServerSocket[]
let serverUrl: string
beforeEach(async () => {
connections = []
// port 0 → let the OS pick a free one
server = new WebSocketServer({ port: 0 })
server.on('connection', (ws) => {
connections.push(ws)
})
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
port = (server.address() as { port: number }).port
serverUrl = `ws://127.0.0.1:${port}`
})
afterEach(async () => {
// Forcibly kill any server-side sockets (not graceful close) so
// WebSocketServer.close() doesn't wait for client FIN.
for (const ws of connections) {
try { ws.terminate() } catch {}
}
await new Promise<void>((resolve) => {
const t = setTimeout(() => resolve(), 500) // hard cap
server.close(() => {
clearTimeout(t)
resolve()
})
})
})
it('processes handler calls in strict FIFO order per chatId', async () => {
const bridge = new WsBridge(serverUrl, 'test')
const events: string[] = []
// The handler simulates an async side effect that takes varying time.
// If handlers ran concurrently, fast msgs could finish before slow ones,
// producing an out-of-order `events` array.
bridge.onServerMessage('chat-1', async (msg: any) => {
const tag = msg.tag as string
const delay = msg.delay as number
events.push(`start:${tag}`)
await new Promise((r) => setTimeout(r, delay))
events.push(`end:${tag}`)
})
bridge.connectSession('chat-1', 'sess-1')
const ok = await bridge.waitForOpen('chat-1')
expect(ok).toBe(true)
expect(connections.length).toBe(1)
const serverWs = connections[0]!
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
// Wait long enough for all three handlers to run serially
await new Promise((r) => setTimeout(r, 200))
expect(events).toEqual([
'start:1', 'end:1',
'start:2', 'end:2',
'start:3', 'end:3',
])
bridge.destroy()
})
it('handler error does not break the chain (subsequent messages still run)', async () => {
const bridge = new WsBridge(serverUrl, 'test')
const events: string[] = []
bridge.onServerMessage('chat-err', async (msg: any) => {
if (msg.throw) {
events.push('throwing')
throw new Error('boom')
}
events.push(`ok:${msg.tag}`)
})
bridge.connectSession('chat-err', 'sess-err')
await bridge.waitForOpen('chat-err')
const serverWs = connections[0]!
serverWs.send(JSON.stringify({ throw: true }))
serverWs.send(JSON.stringify({ tag: 'after' }))
await new Promise((r) => setTimeout(r, 80))
expect(events).toEqual(['throwing', 'ok:after'])
bridge.destroy()
})
it('resetSession clears the handler chain', async () => {
const bridge = new WsBridge(serverUrl, 'test')
bridge.onServerMessage('chat-reset', () => {})
bridge.connectSession('chat-reset', 'sess-reset')
await bridge.waitForOpen('chat-reset')
bridge.resetSession('chat-reset')
expect(bridge.hasSession('chat-reset')).toBe(false)
bridge.destroy()
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'bun:test'
import {
checkAttachmentLimit,
IMAGE_MAX_BYTES,
FILE_MAX_BYTES,
IMAGE_MIME_WHITELIST,
} from '../attachment-limits.js'
describe('checkAttachmentLimit', () => {
it('accepts a 1 MB PNG image', () => {
const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
expect(result.ok).toBe(true)
})
it('rejects an 11 MB image as too_large', () => {
const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.reason).toBe('too_large')
expect(result.hint).toContain('10')
}
})
it('rejects an unsupported image mime', () => {
const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
})
it('rejects image/heic (not supported by Claude API)', () => {
const result = checkAttachmentLimit('image', 500_000, 'image/heic')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
})
it('accepts a 10 MB PDF file', () => {
const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
expect(result.ok).toBe(true)
})
it('rejects a 31 MB file as too_large', () => {
const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('too_large')
})
it('exposes the limits as exports', () => {
expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
expect(IMAGE_MIME_WHITELIST).toContain('image/png')
})
})
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import { AttachmentStore } from '../attachment-store.js'
let tmpRoot: string
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
})
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true })
})
describe('AttachmentStore', () => {
it('writes a buffer and returns the absolute path', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
const written = await store.write(target, Buffer.from('PNGDATA'))
expect(path.isAbsolute(written)).toBe(true)
const content = await fs.readFile(written)
expect(content.toString()).toBe('PNGDATA')
})
it('writes under {root}/{platform}/{sessionId}/', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
expect(target).toContain(path.join('telegram', 'sess-42'))
expect(target.endsWith('foo.pdf')).toBe(true)
})
it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
// The resulting target must still live inside the store root.
const root = path.resolve(tmpRoot)
expect(path.resolve(target).startsWith(root)).toBe(true)
expect(path.basename(target)).not.toContain('..')
expect(path.basename(target)).not.toContain('/')
})
it('collapses name collisions by prefixing timestamps', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const a = store.resolvePath('feishu', 'sess-1', 'image.png')
await store.write(a, Buffer.from('first'))
const b = store.resolvePath('feishu', 'sess-1', 'image.png')
expect(b).not.toBe(a)
await store.write(b, Buffer.from('second'))
const contentB = await fs.readFile(b)
expect(contentB.toString()).toBe('second')
})
it('gc() removes files older than retentionMs and reports counts', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
await store.write(target, Buffer.from('STALE'))
// Age the file manually
const past = new Date(Date.now() - 10_000)
await fs.utimes(target, past, past)
const result = await store.gc()
expect(result.removed).toBe(1)
expect(result.bytes).toBe(5)
await expect(fs.access(target)).rejects.toThrow()
})
it('gc() keeps fresh files', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
await store.write(target, Buffer.from('FRESH'))
const result = await store.gc()
expect(result.removed).toBe(0)
await fs.access(target)
})
it('resolvePath under heavy collision pressure returns unique paths', () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
// First create a file so subsequent resolves hit the collision branch
const a = store.resolvePath('feishu', 'sess-1', 'race.png')
fsSync.writeFileSync(a, 'first')
// Collect 50 resolved paths in a tight loop — none should clash
const seen = new Set<string>()
for (let i = 0; i < 50; i++) {
seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
}
expect(seen.size).toBe(50)
for (const p of seen) {
expect(p).not.toBe(a)
}
})
it('gc() cleans orphan .part files after a short grace period', async () => {
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
// Simulate a crashed write — leave a .part tmp file behind
const dir = path.join(tmpRoot, 'feishu', 'sess-1')
await fs.mkdir(dir, { recursive: true })
const orphan = path.join(dir, 'image.png.1234.5678.part')
await fs.writeFile(orphan, 'ORPHAN')
// Age the orphan so gc considers it stale
const past = new Date(Date.now() - 1000)
await fs.utimes(orphan, past, past)
const result = await store.gc()
expect(result.removed).toBeGreaterThanOrEqual(1)
await expect(fs.access(orphan)).rejects.toThrow()
})
})
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'bun:test'
import { ImageBlockWatcher } from '../image-block-watcher.js'
describe('ImageBlockWatcher', () => {
it('extracts a markdown image with http URL', () => {
const w = new ImageBlockWatcher()
const out = w.feed('Here is ![alt](https://example.com/foo.png) an image.')
expect(out.length).toBe(1)
const source = out[0]!.source
expect(source.kind).toBe('url')
if (source.kind === 'url') {
expect(source.url).toBe('https://example.com/foo.png')
}
expect(out[0]!.alt).toBe('alt')
})
it('extracts a markdown image with absolute local path', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![cat](/tmp/cat.jpg)')
expect(out.length).toBe(1)
const source = out[0]!.source
expect(source.kind).toBe('path')
if (source.kind === 'path') {
expect(source.path).toBe('/tmp/cat.jpg')
}
})
it('extracts a markdown image with file:// URL as path', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![x](file:///var/img/x.png)')
const source = out[0]!.source
expect(source.kind).toBe('path')
if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png')
})
it('extracts a data URI as base64', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![inline](data:image/png;base64,AAAA)')
const source = out[0]!.source
expect(source.kind).toBe('base64')
if (source.kind === 'base64') {
expect(source.mime).toBe('image/png')
expect(source.data).toBe('AAAA')
}
})
it('deduplicates the same image across multiple feeds', () => {
const w = new ImageBlockWatcher()
const a = w.feed('![](https://x/y.png)')
const b = w.feed(' repeated ![](https://x/y.png) again')
expect(a.length).toBe(1)
expect(b.length).toBe(0)
})
it('handles images split across feed boundaries', () => {
const w = new ImageBlockWatcher()
const a = w.feed('a ![al')
const b = w.feed('t](/tmp/x.png) b')
expect(a.length).toBe(0)
expect(b.length).toBe(1)
const source = b[0]!.source
expect(source.kind).toBe('path')
if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png')
})
it('skips non-image markdown links', () => {
const w = new ImageBlockWatcher()
const out = w.feed('See [docs](https://example.com).')
expect(out.length).toBe(0)
})
it('drain() returns all accumulated uploads', () => {
const w = new ImageBlockWatcher()
w.feed('![a](/tmp/a.png)')
w.feed(' and ![b](/tmp/b.png)')
const all = w.drain()
expect(all.length).toBe(2)
})
it('reset() clears buffer, seen set, and accumulated list', () => {
const w = new ImageBlockWatcher()
w.feed('![a](/tmp/a.png)')
w.reset()
// After reset, drain() is empty
expect(w.drain().length).toBe(0)
// And re-feeding the same image yields a fresh emit (dedup state cleared)
const out = w.feed('![a](/tmp/a.png)')
expect(out.length).toBe(1)
})
it('skips relative paths (cannot be resolved safely)', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)')
expect(out.length).toBe(1)
const source = out[0]!.source
expect(source.kind).toBe('path')
if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
})
it('extracts multiple images from a single feed chunk in order', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)')
expect(out.length).toBe(3)
expect(out[0]!.source.kind).toBe('path')
expect(out[1]!.source.kind).toBe('url')
expect(out[2]!.source.kind).toBe('base64')
})
it('rejects malformed data URI (not base64)', () => {
const w = new ImageBlockWatcher()
const out = w.feed('![bad](data:image/png,ABC)')
// Not in `;base64,` form → classify returns null → skipped
expect(out.length).toBe(0)
})
})
@@ -0,0 +1,58 @@
/**
* Size and MIME restrictions for IM attachments.
*
* Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file)
* and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's
* own image size bounds.
*/
export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB
export const IMAGE_MIME_WHITELIST = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
] as const
export type LimitCheckResult =
| { ok: true }
| { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string }
function formatMb(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1)
}
export function checkAttachmentLimit(
kind: 'image' | 'file',
size: number,
mime?: string,
): LimitCheckResult {
if (kind === 'image') {
if (size > IMAGE_MAX_BYTES) {
return {
ok: false,
reason: 'too_large',
hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`,
}
}
if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) {
return {
ok: false,
reason: 'unsupported_mime',
hint: `📎 暂不支持此图片格式(${mime})`,
}
}
return { ok: true }
}
// kind === 'file'
if (size > FILE_MAX_BYTES) {
return {
ok: false,
reason: 'too_large',
hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`,
}
}
return { ok: true }
}
@@ -0,0 +1,121 @@
/**
* Local staging directory for IM-downloaded resources.
*
* Layout: {root}/{platform}/{sessionId}/{safeName}
* Default root: ~/.claude/im-downloads
*
* Responsibilities:
* - Generate unique, safe paths from (platform, sessionId, originalName)
* - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
* - GC files that haven't been touched for `retentionMs` (default 24h)
*/
import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs'
import type { Dirent } from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import type { ImPlatform } from './attachment-types.js'
export interface AttachmentStoreConfig {
root: string
retentionMs: number
/** Grace window before a `.part` orphan (left behind by a crashed writer)
* is eligible for GC. Default 10 minutes. */
orphanGraceMs: number
}
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
function defaultRoot(): string {
return path.join(os.homedir(), '.claude', 'im-downloads')
}
/** Strip path separators / .. / control chars from a filename. */
function sanitizeFilename(name: string): string {
// eslint-disable-next-line no-control-regex
const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
return cleaned.trim() || 'unnamed'
}
export class AttachmentStore {
private readonly root: string
private readonly retentionMs: number
private readonly orphanGraceMs: number
constructor(config?: Partial<AttachmentStoreConfig>) {
this.root = config?.root ?? defaultRoot()
this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
}
/** Compute the target path. Creates parent dirs on demand.
* If a file with the same name already exists, prefix with a timestamp
* to avoid clobbering. */
resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
const safeSession = sanitizeFilename(sessionId)
const dir = path.join(this.root, platform, safeSession)
fsSync.mkdirSync(dir, { recursive: true })
const safeName = sanitizeFilename(name)
const candidate = path.join(dir, safeName)
if (!fsSync.existsSync(candidate)) return candidate
const { name: base, ext } = path.parse(safeName)
// Collisions are rare in practice, but multiple downloads landing in the
// same millisecond must still produce unique paths — append a random
// suffix so the bare timestamp alone never clashes.
const rand = Math.random().toString(36).slice(2, 8)
return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
}
/** Write atomically: stream to {target}.part, then rename. */
async write(target: string, data: Buffer): Promise<string> {
await fs.mkdir(path.dirname(target), { recursive: true })
const tmp = `${target}.${process.pid}.${Date.now()}.part`
await fs.writeFile(tmp, data)
await fs.rename(tmp, target)
return target
}
/** Remove files older than retentionMs. Returns summary. */
async gc(): Promise<{ removed: number; bytes: number }> {
let removed = 0
let bytes = 0
const now = Date.now()
const walk = async (dir: string): Promise<void> => {
let entries: Dirent<string>[]
try {
// Pass encoding explicitly so Dirent stays string-typed under
// newer @types/node where the Buffer overload becomes the default.
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(full)
} else if (entry.isFile()) {
try {
const stat = await fs.stat(full)
const age = now - stat.mtimeMs
const isOrphanPart = entry.name.endsWith('.part')
const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
if (age > threshold) {
bytes += stat.size
await fs.unlink(full)
removed++
}
} catch {
// ignore races
}
}
}
}
await walk(this.root).catch(() => {})
return { removed, bytes }
}
}
@@ -0,0 +1,29 @@
/**
* Shared attachment types for IM adapters.
*/
import type { AttachmentRef } from '../ws-bridge.js'
export type { AttachmentRef }
/** Platform tag — used for local staging subdir and telemetry. */
export type ImPlatform = 'feishu' | 'telegram'
/** Result of downloading an IM resource into the local stage dir. */
export interface LocalAttachment {
kind: 'image' | 'file'
name: string // original filename, or synthesized if none
path: string // absolute path on disk (under ~/.claude/im-downloads)
size: number // bytes
mimeType: string // detected or provided
buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
}
/** Pending outbound media found in Agent stream output. */
export interface PendingUpload {
id: string // fingerprint, used for dedup
source:
| { kind: 'base64'; data: string; mime: string }
| { kind: 'path'; path: string; mime?: string }
| { kind: 'url'; url: string; mime?: string }
alt?: string
}
@@ -0,0 +1,94 @@
/**
* Pure, stateful extractor that watches a stream of assistant text for
* markdown image references (`![alt](source)`) and emits PendingUpload
* records. Used by IM adapters to know which images to upload to IM.
*
* - Buffers input so an image marker split across multiple feed() calls
* still gets detected.
* - Dedups by fingerprint of the source so the same image is only emitted
* once per watcher lifetime.
*/
import type { PendingUpload } from './attachment-types.js'
// Matches a complete markdown image: ![alt](target)
// `alt` may be empty; `target` stops at the first closing paren.
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
function fingerprint(raw: string): string {
let h = 5381
for (let i = 0; i < raw.length; i++) {
h = ((h << 5) + h) ^ raw.charCodeAt(i)
}
return (h >>> 0).toString(16)
}
function classify(target: string): PendingUpload['source'] | null {
if (target.startsWith('data:')) {
const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
if (!m) return null
return { kind: 'base64', mime: m[1]!, data: m[2]! }
}
if (target.startsWith('file://')) {
return { kind: 'path', path: target.slice('file://'.length) }
}
if (target.startsWith('http://') || target.startsWith('https://')) {
return { kind: 'url', url: target }
}
if (target.startsWith('/')) {
return { kind: 'path', path: target }
}
return null // relative paths — skip, we can't resolve them safely
}
export class ImageBlockWatcher {
private buffer = ''
private seen = new Set<string>()
private accumulated: PendingUpload[] = []
/** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
feed(chunk: string): PendingUpload[] {
this.buffer += chunk
const out: PendingUpload[] = []
IMAGE_RE.lastIndex = 0
let lastConsumedEnd = 0
let m: RegExpExecArray | null
while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
const [, alt, target] = m
const source = classify(target!)
if (source) {
const id = fingerprint(`${source.kind}:${target}`)
if (!this.seen.has(id)) {
this.seen.add(id)
const pending: PendingUpload = { id, source, alt: alt || undefined }
out.push(pending)
this.accumulated.push(pending)
}
}
lastConsumedEnd = m.index + m[0].length
}
// Preserve tail that might contain a partially-received marker.
if (lastConsumedEnd > 0) {
this.buffer = this.buffer.slice(lastConsumedEnd)
}
if (this.buffer.length > 4096) {
this.buffer = this.buffer.slice(-2048)
}
return out
}
/** Return everything seen so far (for end-of-stream reconciliation). */
drain(): PendingUpload[] {
return [...this.accumulated]
}
/** Reset watcher state (use at /clear or new session). */
reset(): void {
this.buffer = ''
this.seen.clear()
this.accumulated = []
}
}
@@ -0,0 +1,24 @@
/**
* 会话串行队列
*
* 同一 chatId 的消息串行处理,防并发冲突。
* 不同 chatId 之间互不影响。
* 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
*/
const queues = new Map<string, Promise<void>>()
export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
const prev = queues.get(chatId) ?? Promise.resolve()
const next = prev.then(fn, () => fn()).catch((err) => {
console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
})
queues.set(chatId, next)
// Clean up after completion to avoid memory leak for one-off chats
next.finally(() => {
if (queues.get(chatId) === next) {
queues.delete(chatId)
}
})
return next
}
@@ -0,0 +1,96 @@
/**
* Adapter 配置加载
*
* 优先级:环境变量 > ~/.claude/adapters.json > 默认值
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
export type PairedUser = {
userId: string | number
displayName: string
pairedAt: number
}
export type PairingState = {
code: string | null
expiresAt: number | null
createdAt: number | null
}
export type TelegramConfig = {
botToken: string
allowedUsers: number[]
pairedUsers: PairedUser[]
defaultWorkDir: string
}
export type FeishuConfig = {
appId: string
appSecret: string
encryptKey: string
verificationToken: string
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
streamingCard: boolean
}
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string
pairing: PairingState
telegram: TelegramConfig
feishu: FeishuConfig
}
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapters.json')
}
function loadFile(): Record<string, any> {
try {
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
} catch (err: any) {
if (err?.code !== 'ENOENT') {
console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
}
return {}
}
}
export function loadConfig(): AdapterConfig {
const file = loadFile()
const tg = file.telegram ?? {}
const fs_ = file.feishu ?? {}
const pairing = file.pairing ?? {}
return {
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
defaultProjectDir: file.defaultProjectDir || '',
pairing: {
code: pairing.code ?? null,
expiresAt: pairing.expiresAt ?? null,
createdAt: pairing.createdAt ?? null,
},
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
allowedUsers: tg.allowedUsers ?? [],
pairedUsers: tg.pairedUsers ?? [],
defaultWorkDir: tg.defaultWorkDir || process.cwd(),
},
feishu: {
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
allowedUsers: fs_.allowedUsers ?? [],
pairedUsers: fs_.pairedUsers ?? [],
defaultWorkDir: fs_.defaultWorkDir || process.cwd(),
streamingCard: fs_.streamingCard ?? false,
},
}
}
@@ -0,0 +1,229 @@
/**
* 消息格式化工具
*/
type AdapterChatState =
| 'idle'
| 'thinking'
| 'streaming'
| 'tool_executing'
| 'permission_pending'
type ImStatusSummary = {
sessionId?: string
projectName?: string | null
branch?: string | null
model?: string | null
state?: AdapterChatState | null
verb?: string | null
pendingPermissionCount?: number
taskCounts?: {
total: number
pending: number
inProgress: number
completed: number
}
}
const IM_HELP_LINES = [
'/new [项目] — 新建会话或切换项目',
'/projects — 查看最近项目',
'/status — 查看当前会话状态',
'/clear — 清空当前会话上下文',
'/stop — 停止当前生成',
'/help — 显示这份帮助',
]
/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
export function splitMessage(text: string, limit: number): string[] {
if (text.length <= limit) return [text]
const chunks: string[] = []
let remaining = text
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining)
break
}
let splitAt = remaining.lastIndexOf('\n\n', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
if (splitAt <= 0) splitAt = limit
// Include the delimiter for paragraph/sentence breaks
if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
chunks.push(remaining.slice(0, splitAt).trimEnd())
remaining = remaining.slice(splitAt).trimStart()
}
return chunks
}
/** Format tool use info for display in IM. */
export function formatToolUse(toolName: string, input: unknown): string {
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
const summary = formatToolSummary(toolName, inp)
if (summary) return `🔧 ${toolName} ${summary}`
const preview = truncateInput(input, 200)
return `🔧 ${toolName}\n${preview}`
}
/** Generate a concise human-readable summary for common tools. */
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
switch (tool) {
case 'Bash': {
const desc = inp.description as string | undefined
const cmd = inp.command as string | undefined
if (desc) return desc
if (cmd) return truncate(cmd, 120)
return null
}
case 'Read': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Edit': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Write': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Grep': {
const pat = inp.pattern as string | undefined
const p = inp.path as string | undefined
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
return null
}
case 'Glob': {
const pat = inp.pattern as string | undefined
return pat ? `"${pat}"` : null
}
case 'Skill': {
const skill = inp.skill as string | undefined
return skill || null
}
case 'Agent': {
const desc = inp.description as string | undefined
return desc || null
}
case 'WebFetch': {
const url = inp.url as string | undefined
return url ? truncate(url, 120) : null
}
case 'WebSearch': {
const q = inp.query as string | undefined
return q ? `"${truncate(q, 80)}"` : null
}
default:
return null
}
}
function shortPath(fp: string): string {
const parts = fp.split('/')
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '…' : s
}
/** Format a permission request for display in IM. */
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
const preview = truncateInput(input, 300)
return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
}
/** Truncate tool input to a preview string. */
export function truncateInput(input: unknown, maxLen: number): string {
try {
const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
} catch {
return '(unserializable)'
}
}
/** Escape special characters for Telegram MarkdownV2. */
export function escapeMarkdownV2(text: string): string {
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
}
export function formatImHelp(): string {
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
}
export function formatImStatus(summary: ImStatusSummary | null): string {
if (!summary?.sessionId) {
return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
}
const lines = ['当前会话状态:']
if (summary.projectName) {
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
} else if (summary.branch) {
lines.push(`分支: ${summary.branch}`)
}
lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
if (summary.model) {
lines.push(`模型: ${summary.model}`)
}
lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
const pendingPermissionCount = summary.pendingPermissionCount ?? 0
if (pendingPermissionCount > 0) {
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
}
const taskCounts = summary.taskCounts
if (taskCounts && taskCounts.total > 0) {
const taskParts = [`总计 ${taskCounts.total}`]
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
lines.push(`任务: ${taskParts.join(' · ')}`)
}
return lines.join('\n')
}
function formatAdapterChatState(
state: AdapterChatState | null | undefined,
verb: string | null | undefined,
): string {
const label = (() => {
switch (state) {
case 'thinking':
return '思考中'
case 'streaming':
return '生成中'
case 'tool_executing':
return '执行工具中'
case 'permission_pending':
return '等待权限确认'
case 'idle':
default:
return '空闲'
}
})()
if (!verb || verb === 'Thinking') return label
return `${label} (${verb})`
}
function shortSessionId(sessionId: string): string {
return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
}
@@ -0,0 +1,146 @@
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export type GitInfo = {
branch: string | null
repoName: string | null
workDir: string
changedFiles: number
}
export type SessionTask = {
id: string
subject: string
status: 'pending' | 'in_progress' | 'completed'
}
export class AdapterHttpClient {
readonly httpBaseUrl: string
/** Default timeout for HTTP requests (30 seconds) */
private static readonly DEFAULT_TIMEOUT_MS = 30_000
constructor(wsUrl: string) {
this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/$/, '')
}
/** Create an AbortController with timeout */
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
controller: AbortController
timer: ReturnType<typeof setTimeout>
} {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
return { controller, timer }
}
async createSession(workDir: string): Promise<string> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to create session: ${(err as any).message}`)
}
const data = (await res.json()) as { sessionId: string }
return data.sessionId
} finally {
clearTimeout(timer)
}
}
async listRecentProjects(): Promise<RecentProject[]> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
signal: controller.signal,
})
if (!res.ok) {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
} finally {
clearTimeout(timer)
}
}
/**
* Match a project by index (1-based) or fuzzy name from recent projects.
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
*/
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
const projects = await this.listRecentProjects()
// Try as 1-based index
const num = parseInt(query, 10)
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
return { project: projects[num - 1] }
}
const q = query.toLowerCase()
// Exact project name match
const exact = projects.find(p => p.projectName.toLowerCase() === q)
if (exact) return { project: exact }
// Fuzzy: name or path contains query
const matches = projects.filter(p =>
p.projectName.toLowerCase().includes(q) ||
p.realPath.toLowerCase().includes(q)
)
if (matches.length === 1) return { project: matches[0] }
if (matches.length > 1) return { ambiguous: matches }
return {}
}
async getGitInfo(sessionId: string): Promise<GitInfo> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to load git info: ${(err as any).message}`)
}
return (await res.json()) as GitInfo
} finally {
clearTimeout(timer)
}
}
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
signal: controller.signal,
})
if (!res.ok) {
if (res.status === 404) return []
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to load tasks: ${(err as any).message}`)
}
const data = (await res.json()) as { tasks?: SessionTask[] }
return Array.isArray(data.tasks) ? data.tasks : []
} finally {
clearTimeout(timer)
}
}
}
@@ -0,0 +1,91 @@
/**
* 流式消息缓冲
*
* 将 content_delta 累积后按时间窗口或字符数批量 flush。
* 用于 Telegram editMessage / 飞书流式卡片更新。
*/
export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
const DEFAULT_INTERVAL_MS = 500
const DEFAULT_CHAR_THRESHOLD = 200
export class MessageBuffer {
private buffer = ''
private timer: ReturnType<typeof setTimeout> | null = null
private flushing = false
private pendingComplete = false
constructor(
private onFlush: FlushCallback,
private intervalMs = DEFAULT_INTERVAL_MS,
private charThreshold = DEFAULT_CHAR_THRESHOLD,
) {}
/** Append text delta. Triggers flush if threshold reached. */
append(text: string): void {
this.buffer += text
if (this.buffer.length >= this.charThreshold) {
this.scheduleFlush()
} else if (!this.timer) {
this.timer = setTimeout(() => this.flush(false), this.intervalMs)
}
}
/** Immediately flush all remaining content (called on message_complete). */
async complete(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
if (this.flushing) {
// A flush is in-flight; mark pending so it fires after current flush finishes
this.pendingComplete = true
return
}
await this.flush(true)
}
/** Reset the buffer for a new message. */
reset(): void {
this.buffer = ''
this.pendingComplete = false
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
private scheduleFlush(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
queueMicrotask(() => this.flush(false))
}
private async flush(isComplete: boolean): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
if (this.flushing) return
if (this.buffer.length === 0) return
this.flushing = true
const text = this.buffer
this.buffer = ''
try {
await this.onFlush(text, isComplete)
} catch (err) {
console.error('[MessageBuffer] Flush error:', err)
} finally {
this.flushing = false
// If complete() was called while we were flushing, do the final flush now
if (this.pendingComplete) {
this.pendingComplete = false
await this.flush(true)
}
}
}
}
@@ -0,0 +1,57 @@
/**
* 消息去重
*
* 防止 WebSocket 重连等场景下消息重复处理。
* 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
*/
const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
const DEFAULT_MAX_ENTRIES = 5000
const SWEEP_INTERVAL_MS = 60_000 // 1 minute
export class MessageDedup {
private store = new Map<string, number>()
private sweepTimer: ReturnType<typeof setInterval>
constructor(
private ttlMs = DEFAULT_TTL_MS,
private maxEntries = DEFAULT_MAX_ENTRIES,
) {
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
}
/** Returns true if this is a NEW message, false if duplicate. */
tryRecord(id: string): boolean {
const now = Date.now()
const existing = this.store.get(id)
if (existing !== undefined && now - existing < this.ttlMs) {
return false // duplicate
}
// Evict oldest if at capacity
if (this.store.size >= this.maxEntries) {
const oldest = this.store.keys().next().value
if (oldest !== undefined) this.store.delete(oldest)
}
this.store.set(id, now)
return true
}
private sweep(): void {
const now = Date.now()
for (const [key, ts] of this.store) {
if (now - ts >= this.ttlMs) {
this.store.delete(key)
} else {
break // Map preserves insertion order; once fresh, rest is fresh
}
}
}
destroy(): void {
clearInterval(this.sweepTimer)
this.store.clear()
}
}
@@ -0,0 +1,149 @@
/**
* 配对核心逻辑
*
* - generatePairingCode(): 生成 6 位安全配对码
* - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
* - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import * as crypto from 'node:crypto'
import type { PairedUser, PairingState } from './config.js'
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
const RATE_LIMIT_MAX_ATTEMPTS = 5
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
function isRateLimited(userId: string | number): boolean {
const key = String(userId)
const record = failedAttempts.get(key)
if (!record) return false
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
failedAttempts.delete(key)
return false
}
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
}
function recordFailedAttempt(userId: string | number): void {
const key = String(userId)
const record = failedAttempts.get(key)
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
} else {
record.count++
}
}
const CODE_LENGTH = 6
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapters.json')
}
function readConfigFile(): Record<string, any> {
try {
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
} catch {
return {}
}
}
function writeConfigFile(data: Record<string, any>): void {
const filePath = getConfigPath()
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const tmp = `${filePath}.tmp.${Date.now()}`
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8')
fs.renameSync(tmp, filePath)
}
export function generatePairingCode(): string {
let code = ''
for (let i = 0; i < CODE_LENGTH; i++) {
code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
}
return code
}
/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
export function isPaired(
platform: 'telegram' | 'feishu',
userId: string | number,
config: Record<string, any>,
): boolean {
const platformConfig = config[platform] ?? {}
const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
// allowedUsers 非空时检查
if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
return pairedUsers.some((p) => String(p.userId) === String(userId))
}
/**
* 尝试配对:验证消息文本是否匹配当前有效配对码。
* 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
*/
export function tryPair(
messageText: string,
senderInfo: { userId: string | number; displayName: string },
platform: 'telegram' | 'feishu',
): boolean {
const file = readConfigFile()
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
// 速率限制检查
if (isRateLimited(senderInfo.userId)) return false
// 检查配对码是否有效
if (!pairing.code || !pairing.expiresAt) return false
if (Date.now() > pairing.expiresAt) return false
// 比较(忽略大小写和空格)
const input = messageText.trim().toUpperCase()
if (input !== pairing.code.toUpperCase()) {
recordFailedAttempt(senderInfo.userId)
return false
}
// 配对成功:写入 pairedUsers
const platformConfig = file[platform] ?? {}
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
// 避免重复
const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
if (!exists) {
pairedUsers.push({
userId: senderInfo.userId,
displayName: senderInfo.displayName,
pairedAt: Date.now(),
})
}
// 更新 config
file[platform] = { ...platformConfig, pairedUsers }
file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
writeConfigFile(file)
return true
}
/** 统一的用户授权检查(供各 adapter 调用) */
export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean {
try {
const cfgFile = readConfigFile()
return isPaired(platform, userId, cfgFile)
} catch {
return false
}
}
@@ -0,0 +1,60 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
export type SessionEntry = {
sessionId: string
workDir: string
updatedAt: number
}
type StoreData = Record<string, SessionEntry>
function getDefaultPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapter-sessions.json')
}
export class SessionStore {
private data: StoreData
private filePath: string
constructor(filePath?: string) {
this.filePath = filePath ?? getDefaultPath()
this.data = this.load()
}
get(chatId: string): SessionEntry | null {
return this.data[chatId] ?? null
}
set(chatId: string, sessionId: string, workDir: string): void {
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
this.save()
}
delete(chatId: string): void {
delete this.data[chatId]
this.save()
}
listAll(): Array<{ chatId: string } & SessionEntry> {
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
}
private load(): StoreData {
try {
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
} catch {
return {}
}
}
private save(): void {
const dir = path.dirname(this.filePath)
fs.mkdirSync(dir, { recursive: true })
const tmp = `${this.filePath}.tmp.${Date.now()}`
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
fs.renameSync(tmp, this.filePath)
}
}
@@ -0,0 +1,282 @@
/**
* WebSocket Bridge
*
* 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。
* 管理 chatId → sessionId 映射,自动重连,心跳。
*/
import WebSocket from 'ws'
/** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef.
* The server will either (a) write base64 `data` to
* ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or
* (b) read `path` from disk and inject `@"path"` into the prompt. */
export type AttachmentRef = {
type: 'file' | 'image'
name?: string
path?: string
data?: string // base64 payload (images)
mimeType?: string
}
/** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */
export type ServerMessage = {
type: string
[key: string]: any
}
/** Callback for server messages */
export type MessageHandler = (msg: ServerMessage) => void
type Session = {
sessionId: string
ws: WebSocket
reconnectAttempts: number
reconnectTimer: ReturnType<typeof setTimeout> | null
}
const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_MS = 1000
const RECONNECT_MAX_MS = 30_000
const MAX_RECONNECT_ATTEMPTS = 10
export class WsBridge {
private sessions = new Map<string, Session>()
/** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
private handlers = new Map<string, MessageHandler>()
/** Per-chat FIFO queue of in-flight handler promises.
* Ensures an async handler for message N completes before handler for N+1
* starts, preventing state races at `await` points. */
private handlerChains = new Map<string, Promise<void>>()
private serverUrl: string
private platform: string
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private destroyed = false
constructor(serverUrl: string, platform: string) {
this.serverUrl = serverUrl.replace(/\/$/, '')
this.platform = platform
this.startHeartbeat()
}
/** Connect to a session with a known sessionId. Returns false if already connected. */
connectSession(chatId: string, sessionId: string): boolean {
const existing = this.sessions.get(chatId)
if (existing && existing.ws.readyState === WebSocket.OPEN) {
return false
}
this.connect(chatId, sessionId)
return true
}
/** Send a user message to the session bound to chatId. */
sendUserMessage(
chatId: string,
content: string,
attachments?: AttachmentRef[],
): boolean {
const payload: Record<string, unknown> = { type: 'user_message', content }
if (attachments && attachments.length > 0) {
payload.attachments = attachments
}
return this.send(chatId, payload)
}
/** Respond to a permission request.
*
* @param rule - optional rule name to make the permission persistent.
* Currently the server supports `'always'`, which uses the CLI's
* permission_suggestions to produce updatedPermissions so the same
* tool call won't prompt again in this session. Omit for one-shot allow. */
sendPermissionResponse(
chatId: string,
requestId: string,
allowed: boolean,
rule?: string,
): boolean {
const message: Record<string, unknown> = {
type: 'permission_response',
requestId,
allowed,
}
if (rule) message.rule = rule
return this.send(chatId, message)
}
/** Stop the current generation. */
sendStopGeneration(chatId: string): boolean {
return this.send(chatId, { type: 'stop_generation' })
}
/** Register (or replace) the handler for server messages on a specific chatId. */
onServerMessage(chatId: string, handler: MessageHandler): void {
this.handlers.set(chatId, handler)
}
/** Reset session for a chatId (e.g. /new command). */
resetSession(chatId: string): void {
const session = this.sessions.get(chatId)
if (session) {
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
session.ws.close(1000, 'session reset')
this.sessions.delete(chatId)
}
this.handlers.delete(chatId)
this.handlerChains.delete(chatId)
}
/** Has a session (connected or handler registered) for chatId. */
hasSession(chatId: string): boolean {
return this.sessions.has(chatId) || this.handlers.has(chatId)
}
/** Destroy all sessions. */
destroy(): void {
this.destroyed = true
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
for (const [, session] of this.sessions) {
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
session.ws.close(1000, 'bridge destroyed')
}
this.sessions.clear()
this.handlers.clear()
this.handlerChains.clear()
}
// ------- internal -------
private connect(chatId: string, sessionId: string): void {
const url = `${this.serverUrl}/ws/${sessionId}`
const ws = new WebSocket(url)
// Cancel any pending reconnect timer for this chatId
const prev = this.sessions.get(chatId)
if (prev) {
if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
prev.ws.removeAllListeners()
}
const session: Session = {
sessionId,
ws,
reconnectAttempts: prev?.reconnectAttempts ?? 0,
reconnectTimer: null,
}
this.sessions.set(chatId, session)
ws.on('open', () => {
console.log(`[WsBridge] Connected: ${sessionId}`)
session.reconnectAttempts = 0
})
ws.on('message', (raw) => {
let msg: ServerMessage
try {
msg = JSON.parse(raw.toString())
} catch (err) {
console.error('[WsBridge] Parse error:', err)
return
}
if (msg.type === 'pong') return
const handler = this.handlers.get(chatId)
if (!handler) return
// Serialize per-chat handler calls: chain each message onto the previous
// one so a slow handler (e.g. one awaiting im.message.create) fully
// finishes before the next message's handler runs. This prevents state
// races where a later message reads stale map entries set up by an
// earlier-but-still-in-flight handler.
const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
const next = prev
.catch(() => {}) // upstream errors must not poison the chain
.then(() => Promise.resolve().then(() => handler(msg)))
.catch((err) => {
console.error(`[WsBridge] Handler error on ${chatId}:`, err)
})
this.handlerChains.set(chatId, next)
})
ws.on('close', (code, reason) => {
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
if (code === 1000) return
this.scheduleReconnect(chatId, sessionId)
})
ws.on('error', (err) => {
console.error(`[WsBridge] Error on ${sessionId}:`, err.message)
})
}
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
const session = this.sessions.get(chatId)
if (!session) return Promise.resolve(false)
if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup()
resolve(false)
}, timeoutMs)
const onOpen = () => { cleanup(); resolve(true) }
const onError = () => { cleanup(); resolve(false) }
const onClose = () => { cleanup(); resolve(false) }
const cleanup = () => {
clearTimeout(timer)
session.ws.removeListener('open', onOpen)
session.ws.removeListener('error', onError)
session.ws.removeListener('close', onClose)
}
session.ws.once('open', onOpen)
session.ws.once('error', onError)
session.ws.once('close', onClose)
})
}
private send(chatId: string, message: Record<string, unknown>): boolean {
const session = this.sessions.get(chatId)
if (!session || session.ws.readyState !== WebSocket.OPEN) {
console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
return false
}
session.ws.send(JSON.stringify(message))
return true
}
private scheduleReconnect(chatId: string, sessionId: string): void {
if (this.destroyed) return
const session = this.sessions.get(chatId)
if (!session) return
if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`)
this.sessions.delete(chatId)
this.handlers.delete(chatId)
return
}
session.reconnectAttempts++
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1),
RECONNECT_MAX_MS,
)
console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
session.reconnectTimer = setTimeout(() => {
if (this.destroyed) return
if (this.sessions.get(chatId)?.sessionId === sessionId) {
this.connect(chatId, sessionId)
}
}, delay)
}
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
for (const [, session] of this.sessions) {
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: 'ping' }))
}
}
}, HEARTBEAT_INTERVAL_MS)
}
}
@@ -0,0 +1,194 @@
/**
* card-errors 单元测试
*/
import { describe, it, expect } from 'bun:test'
import {
CARD_ERROR,
CARD_CONTENT_SUB_ERROR,
extractLarkApiCode,
extractSubCode,
parseCardApiError,
isCardRateLimitError,
isCardTableLimitError,
} from '../card-errors.js'
describe('extractLarkApiCode', () => {
it('从 err.code 直接提取', () => {
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
})
it('从 err.data.code 提取', () => {
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
})
it('从 err.response.data.code 提取(Axios 风格)', () => {
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
})
it('数字字符串被强制转成 number', () => {
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
})
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
const err = {
code: 1,
data: { code: 2 },
response: { data: { code: 3 } },
}
expect(extractLarkApiCode(err)).toBe(1)
})
it('none → undefined', () => {
expect(extractLarkApiCode({})).toBeUndefined()
expect(extractLarkApiCode(null)).toBeUndefined()
expect(extractLarkApiCode(undefined)).toBeUndefined()
expect(extractLarkApiCode('just a string')).toBeUndefined()
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
})
it('非有限数字被忽略', () => {
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
})
})
describe('extractSubCode', () => {
it('识别标准的 ErrCode: 11310', () => {
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
expect(extractSubCode(msg)).toBe(11310)
})
it('无 ErrCode 时返回 null', () => {
expect(extractSubCode('random error message')).toBeNull()
expect(extractSubCode('')).toBeNull()
})
it('ErrCode 大小写容错(冒号后多空格)', () => {
expect(extractSubCode('ErrCode: 42')).toBe(42)
})
})
describe('parseCardApiError', () => {
it('从 SDK 风格错误提取完整结构', () => {
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
const parsed = parseCardApiError(err)
expect(parsed).toEqual({
code: 230099,
subCode: 11310,
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
})
})
it('从 Axios 风格错误(response.data.msg)提取', () => {
const err = {
response: {
data: {
code: 230020,
msg: 'rate limited',
},
},
}
const parsed = parseCardApiError(err)
expect(parsed?.code).toBe(230020)
expect(parsed?.errMsg).toBe('rate limited')
expect(parsed?.subCode).toBeNull()
})
it('无 code 时返回 null', () => {
expect(parseCardApiError({})).toBeNull()
expect(parseCardApiError(null)).toBeNull()
expect(parseCardApiError('string')).toBeNull()
})
it('有 code 无 msg 时 errMsg 为空字符串', () => {
const parsed = parseCardApiError({ code: 230020 })
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
})
it('fallback 到 err.message', () => {
const err = Object.assign(new Error('fallback text'), { code: 230099 })
const parsed = parseCardApiError(err)
expect(parsed?.errMsg).toBe('fallback text')
})
})
describe('isCardRateLimitError', () => {
it('识别 230020', () => {
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
})
it('识别 Axios 风格 230020', () => {
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
})
it('不匹配其他 code', () => {
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
})
it('非错误对象返回 false', () => {
expect(isCardRateLimitError(null)).toBe(false)
expect(isCardRateLimitError({})).toBe(false)
expect(isCardRateLimitError(new Error('random'))).toBe(false)
})
})
describe('isCardTableLimitError', () => {
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
expect(isCardTableLimitError(err)).toBe(true)
})
it('从 Axios 风格的 response.data 匹配', () => {
const err = {
response: {
data: {
code: 230099,
msg: validMsg,
},
},
}
expect(isCardTableLimitError(err)).toBe(true)
})
it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
const err = {
code: CARD_ERROR.CARD_CONTENT_FAILED,
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
}
expect(isCardTableLimitError(err)).toBe(false)
})
it('code 不是 230099 → false', () => {
const err = { code: 230020, msg: validMsg }
expect(isCardTableLimitError(err)).toBe(false)
})
it('没有 subCode → false', () => {
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
expect(isCardTableLimitError(err)).toBe(false)
})
it('不区分 "table number" 的大小写', () => {
const err = {
code: 230099,
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
}
expect(isCardTableLimitError(err)).toBe(true)
})
})
describe('常量值', () => {
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
})
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
})
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
})
})
@@ -0,0 +1,295 @@
/**
* cardkit.ts 单元测试
*
* 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
* - 每个函数构造的 payload 结构
* - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
* - 缺失关键字段时抛错
* - sequence 正确传递
*/
import { describe, it, expect } from 'bun:test'
import {
createCardEntity,
sendCardAsMessage,
streamCardContent,
setCardStreamingMode,
updateCardKitCard,
CardKitApiError,
STREAMING_ELEMENT_ID,
} from '../cardkit.js'
import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
// ---------------------------------------------------------------------------
// Mock client factory
// ---------------------------------------------------------------------------
type MockCall = { api: string; args: any }
function makeMockClient(responses: Record<string, any>) {
const calls: MockCall[] = []
const recorder = (api: string, resp: any) => async (args: any) => {
calls.push({ api, args })
if (typeof resp === 'function') {
return resp(args)
}
return resp
}
const client: any = {
cardkit: {
v1: {
card: {
create: recorder('cardkit.v1.card.create', responses['card.create']),
settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
update: recorder('cardkit.v1.card.update', responses['card.update']),
},
cardElement: {
content: recorder(
'cardkit.v1.cardElement.content',
responses['cardElement.content'],
),
},
},
},
im: {
message: {
create: recorder('im.message.create', responses['im.message.create']),
reply: recorder('im.message.reply', responses['im.message.reply']),
},
},
}
return { client, calls }
}
// ---------------------------------------------------------------------------
// createCardEntity
// ---------------------------------------------------------------------------
describe('createCardEntity', () => {
it('构造 card_json payload 并返回 card_id', async () => {
const { client, calls } = makeMockClient({
'card.create': {
code: 0,
data: { card_id: 'ck_abc_123' },
},
})
const card = { schema: '2.0', body: { elements: [] } }
const id = await createCardEntity(client, card)
expect(id).toBe('ck_abc_123')
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
expect(calls[0]!.args.data.type).toBe('card_json')
// data.data 应当是 card 的 JSON 字符串
expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
})
it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, card_id: 'top_level_id' },
})
const id = await createCardEntity(client, {})
expect(id).toBe('top_level_id')
})
it('non-zero code 抛 CardKitApiError', async () => {
const { client } = makeMockClient({
'card.create': { code: 230099, msg: 'something failed' },
})
await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
})
it('code=0 但缺 card_id 抛错', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: {} },
})
await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
})
})
// ---------------------------------------------------------------------------
// sendCardAsMessage
// ---------------------------------------------------------------------------
describe('sendCardAsMessage', () => {
it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
const { client, calls } = makeMockClient({
'im.message.create': { data: { message_id: 'om_new_msg_1' } },
})
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
expect(mid).toBe('om_new_msg_1')
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('im.message.create')
expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
expect(calls[0]!.args.data.msg_type).toBe('interactive')
// content 格式: {"type":"card","data":{"card_id":"xxx"}}
const parsed = JSON.parse(calls[0]!.args.data.content)
expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
})
it('有 replyTo: 走 im.message.reply', async () => {
const { client, calls } = makeMockClient({
'im.message.reply': { data: { message_id: 'om_reply_1' } },
})
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
expect(mid).toBe('om_reply_1')
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('im.message.reply')
expect(calls[0]!.args.path.message_id).toBe('om_parent')
const parsed = JSON.parse(calls[0]!.args.data.content)
expect(parsed.data.card_id).toBe('ck_id_xyz')
})
it('缺 message_id 抛错', async () => {
const { client } = makeMockClient({
'im.message.create': { data: {} },
})
await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
/missing message_id/,
)
})
})
// ---------------------------------------------------------------------------
// streamCardContent
// ---------------------------------------------------------------------------
describe('streamCardContent', () => {
it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
const { client, calls } = makeMockClient({
'cardElement.content': { code: 0 },
})
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
expect(calls[0]!.args.path).toEqual({
card_id: 'ck_abc',
element_id: STREAMING_ELEMENT_ID,
})
})
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
})
it('230020 响应可被 isCardRateLimitError 识别', async () => {
const { client } = makeMockClient({
'cardElement.content': { code: 230020, msg: 'rate limited' },
})
try {
await streamCardContent(client, 'ck', 'el', 'x', 1)
expect('should have thrown').toBe('but did not')
} catch (err) {
expect(err).toBeInstanceOf(CardKitApiError)
expect(isCardRateLimitError(err)).toBe(true)
}
})
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
const { client } = makeMockClient({
'cardElement.content': {
code: 230099,
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
},
})
try {
await streamCardContent(client, 'ck', 'el', 'x', 1)
expect('should have thrown').toBe('but did not')
} catch (err) {
expect(err).toBeInstanceOf(CardKitApiError)
expect(isCardTableLimitError(err)).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// setCardStreamingMode
// ---------------------------------------------------------------------------
describe('setCardStreamingMode', () => {
it('streaming_mode=false + sequence 正确传递', async () => {
const { client, calls } = makeMockClient({
'card.settings': { code: 0 },
})
await setCardStreamingMode(client, 'ck_xxx', false, 99)
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
expect(calls[0]!.args.data.sequence).toBe(99)
// settings 是 JSON 字符串
const settings = JSON.parse(calls[0]!.args.data.settings)
expect(settings).toEqual({ streaming_mode: false })
})
it('streaming_mode=true 也能工作', async () => {
const { client, calls } = makeMockClient({
'card.settings': { code: 0 },
})
await setCardStreamingMode(client, 'ck', true, 1)
const settings = JSON.parse(calls[0]!.args.data.settings)
expect(settings).toEqual({ streaming_mode: true })
})
})
// ---------------------------------------------------------------------------
// updateCardKitCard
// ---------------------------------------------------------------------------
describe('updateCardKitCard', () => {
it('把 card 包装成 card_json payload + sequence', async () => {
const { client, calls } = makeMockClient({
'card.update': { code: 0 },
})
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
await updateCardKitCard(client, 'ck_final', card, 100)
expect(calls.length).toBe(1)
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
expect(calls[0]!.args.data.sequence).toBe(100)
expect(calls[0]!.args.data.card.type).toBe('card_json')
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
})
it('非零 code 抛 CardKitApiError', async () => {
const { client } = makeMockClient({
'card.update': { code: -1, msg: 'bad card' },
})
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
})
})
// ---------------------------------------------------------------------------
// CardKitApiError
// ---------------------------------------------------------------------------
describe('CardKitApiError', () => {
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
const err = new CardKitApiError({
api: 'card.update',
code: 230020,
msg: 'rate limited',
context: 'seq=5',
})
expect(err.code).toBe(230020)
expect(err.msg).toBe('rate limited')
expect(err.name).toBe('CardKitApiError')
expect(isCardRateLimitError(err)).toBe(true)
})
it('消息包含 api 名和 context', () => {
const err = new CardKitApiError({
api: 'cardElement.content',
code: 230099,
msg: 'oops',
context: 'seq=3 len=100',
})
expect(err.message).toContain('cardElement.content')
expect(err.message).toContain('230099')
expect(err.message).toContain('seq=3 len=100')
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'bun:test'
import { extractInboundPayload } from '../extract-payload.js'
describe('extractInboundPayload', () => {
it('pulls text out of a text message', () => {
const result = extractInboundPayload(
JSON.stringify({ text: 'hello world' }),
'text',
)
expect(result.text).toBe('hello world')
expect(result.pendingDownloads).toEqual([])
})
it('pulls text out of a post (rich text) message', () => {
const content = JSON.stringify({
zh_cn: {
content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]],
},
})
const result = extractInboundPayload(content, 'post')
expect(result.text).toBe('hi there')
expect(result.pendingDownloads).toEqual([])
})
it('identifies an image message as a pending image download', () => {
const content = JSON.stringify({ image_key: 'img_key_abc' })
const result = extractInboundPayload(content, 'image')
expect(result.text).toBe('')
expect(result.pendingDownloads).toEqual([
{ kind: 'image', fileKey: 'img_key_abc' },
])
})
it('identifies a file message as a pending file download with file_name', () => {
const content = JSON.stringify({
file_key: 'file_key_xyz',
file_name: 'spec.pdf',
})
const result = extractInboundPayload(content, 'file')
expect(result.pendingDownloads).toEqual([
{ kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' },
])
})
it('identifies file_archive the same way as file', () => {
const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' })
const result = extractInboundPayload(content, 'file_archive')
expect(result.pendingDownloads).toEqual([
{ kind: 'file', fileKey: 'fk1', fileName: 'x.zip' },
])
})
it('extracts img + file elements from a post message', () => {
const content = JSON.stringify({
zh_cn: {
content: [
[{ tag: 'text', text: 'look: ' }],
[{ tag: 'img', image_key: 'img_post_1' }],
[{ tag: 'text', text: ' and ' }],
[{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }],
],
},
})
const result = extractInboundPayload(content, 'post')
expect(result.text).toBe('look: and ')
expect(result.pendingDownloads).toEqual([
{ kind: 'image', fileKey: 'img_post_1' },
{ kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' },
])
})
it('returns empty on malformed JSON', () => {
const result = extractInboundPayload('not json', 'text')
expect(result.text).toBe('')
expect(result.pendingDownloads).toEqual([])
})
})
@@ -0,0 +1,907 @@
/**
* 飞书 Adapter 翻译逻辑测试
*
* 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
*/
import { describe, it, expect } from 'bun:test'
import * as path from 'node:path'
// ---------- helpers extracted from feishu/index.ts for testability ----------
function extractText(content: string, msgType: string): string | null {
try {
const parsed = JSON.parse(content)
if (msgType === 'text') {
return parsed.text ?? null
}
if (msgType === 'post') {
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
return zhContent
.flat()
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
.map((n: any) => n.text ?? n.content ?? '')
.join('')
.trim() || null
}
return null
} catch {
return null
}
}
function isBotMentioned(
mentions: Array<{ id?: { open_id?: string } }> | undefined,
botOpenId: string,
): boolean {
if (!mentions || !botOpenId) return false
return mentions.some((m) => m.id?.open_id === botOpenId)
}
function stripMentions(text: string): string {
return text.replace(/@_user_\d+/g, '').trim()
}
type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
function prettyPath(realPath: string, maxLen = 64): string {
const home = process.env.HOME
let p = realPath
if (home) {
if (p === home) return '~'
if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
}
if (p.length <= maxLen) return p
const tailLen = Math.floor(maxLen * 0.65)
const headLen = maxLen - tailLen - 1
return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
}
function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
const items = projects.slice(0, 10)
const total = projects.length
const subtitleText =
total > items.length
? `共 ${total} 个最近项目,显示前 ${items.length}`
: `共 ${total} 个最近项目`
const rows = items.map((p, i) => {
const branch = p.branch ? ` · *${p.branch}*` : ''
return {
tag: 'column_set',
flex_mode: 'stretch',
horizontal_spacing: '8px',
margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
columns: [
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'markdown',
content: `**${p.projectName}**${branch}`,
},
{
tag: 'markdown',
content: prettyPath(p.realPath, 56),
text_size: 'notation',
margin: '2px 0 0 0',
},
],
},
{
tag: 'column',
width: 'auto',
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '选择' },
type: i === 0 ? 'primary' : 'default',
size: 'small',
value: {
action: 'pick_project',
realPath: p.realPath,
projectName: p.projectName,
},
},
],
},
],
}
})
return {
schema: '2.0',
config: {
wide_screen_mode: true,
update_multi: true,
},
header: {
title: { tag: 'plain_text', content: '📁 选择项目' },
subtitle: { tag: 'plain_text', content: subtitleText },
template: 'blue',
},
body: {
elements: [
...rows,
{ tag: 'hr', margin: '14px 0 0 0' },
{
tag: 'markdown',
content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
text_size: 'notation',
margin: '6px 0 0 0',
},
],
},
}
}
// ---------- permission card helpers (mirrored from feishu/index.ts) ----------
type ToolCallSummary = {
icon: string
label: string
target?: string
filePath?: string
}
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
const rec: Record<string, unknown> =
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const str = (key: string): string | undefined =>
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
switch (toolName) {
case 'Write': {
const fp = str('file_path')
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
}
case 'Edit':
case 'MultiEdit':
case 'NotebookEdit': {
const fp = str('file_path') ?? str('notebook_path')
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
}
case 'Read': {
const fp = str('file_path')
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
}
case 'Bash':
case 'BashOutput': {
return { icon: '🖥️', label: '执行命令', target: str('command') }
}
case 'Grep': {
const pattern = str('pattern')
return {
icon: '🔍',
label: '搜索内容',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'Glob': {
const pattern = str('pattern')
return {
icon: '📁',
label: '查找文件',
target: pattern ? `pattern: ${pattern}` : undefined,
filePath: str('path'),
}
}
case 'WebFetch':
return { icon: '🌐', label: '访问网页', target: str('url') }
case 'WebSearch':
return { icon: '🌐', label: '搜索网页', target: str('query') }
default:
return { icon: '🔧', label: toolName }
}
}
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const abs = path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(workDir, filePath)
const normWork = path.normalize(workDir).replace(/\/+$/, '')
return abs !== normWork && !abs.startsWith(normWork + path.sep)
}
function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s
return s.slice(0, maxLen - 1) + '…'
}
function buildPermissionCard(
toolName: string,
input: unknown,
requestId: string,
workDir?: string,
): Record<string, unknown> {
const summary = summarizeToolCall(toolName, input)
const crossDir = Boolean(
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
)
const elements: Record<string, unknown>[] = [
{
tag: 'markdown',
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
},
]
if (summary.target) {
const shown = summary.filePath
? prettyPath(summary.target, 80)
: truncateTarget(summary.target, 160)
elements.push({
tag: 'markdown',
content: '```\n' + shown + '\n```',
margin: '4px 0 0 0',
})
}
if (crossDir) {
elements.push({
tag: 'markdown',
content: '⚠️ **该操作位于当前项目目录之外**',
margin: '8px 0 0 0',
text_size: 'notation',
})
}
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
elements.push({
tag: 'column_set',
flex_mode: 'stretch',
horizontal_spacing: '8px',
margin: '8px 0 0 0',
columns: [
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
size: 'medium',
value: { action: 'permit', requestId, allowed: true },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '♾️ 永久允许' },
type: 'default',
size: 'medium',
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
},
],
},
{
tag: 'column',
width: 'weighted',
weight: 1,
vertical_align: 'center',
elements: [
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
size: 'medium',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
})
return {
schema: '2.0',
config: {
wide_screen_mode: false,
update_multi: true,
},
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
subtitle: {
tag: 'plain_text',
content: crossDir ? '⚠️ 跨目录操作' : toolName,
},
template: crossDir ? 'red' : 'orange',
padding: '12px 12px 12px 12px',
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
},
body: { elements },
}
}
// ---------- tests ----------
describe('Feishu: event parsing', () => {
describe('extractText', () => {
it('extracts text from text message', () => {
const content = JSON.stringify({ text: 'hello world' })
expect(extractText(content, 'text')).toBe('hello world')
})
it('extracts text from post message (zh_cn)', () => {
const content = JSON.stringify({
zh_cn: {
content: [[
{ tag: 'text', text: 'Hello ' },
{ tag: 'text', text: 'World' },
]],
},
})
expect(extractText(content, 'post')).toBe('Hello World')
})
it('extracts text from post message with md tag', () => {
const content = JSON.stringify({
zh_cn: {
content: [[{ tag: 'md', text: '**bold** text' }]],
},
})
expect(extractText(content, 'post')).toBe('**bold** text')
})
it('returns null for unsupported message types', () => {
expect(extractText('{}', 'image')).toBeNull()
expect(extractText('{}', 'audio')).toBeNull()
})
it('returns null for malformed content', () => {
expect(extractText('not-json', 'text')).toBeNull()
})
it('returns null for empty text', () => {
const content = JSON.stringify({ text: '' })
// empty string is falsy, so ?? null returns ''
expect(extractText(content, 'text')).toBe('')
})
})
describe('isBotMentioned', () => {
const botId = 'ou_bot_123'
it('returns true when bot is mentioned', () => {
const mentions = [
{ id: { open_id: 'ou_user_1' } },
{ id: { open_id: 'ou_bot_123' } },
]
expect(isBotMentioned(mentions, botId)).toBe(true)
})
it('returns false when bot is not mentioned', () => {
const mentions = [
{ id: { open_id: 'ou_user_1' } },
{ id: { open_id: 'ou_user_2' } },
]
expect(isBotMentioned(mentions, botId)).toBe(false)
})
it('returns false for undefined mentions', () => {
expect(isBotMentioned(undefined, botId)).toBe(false)
})
it('returns false for empty mentions', () => {
expect(isBotMentioned([], botId)).toBe(false)
})
})
describe('stripMentions', () => {
it('removes @_user_N patterns', () => {
expect(stripMentions('@_user_1 hello world')).toBe('hello world')
})
it('removes multiple mentions', () => {
expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
})
it('leaves text without mentions unchanged', () => {
expect(stripMentions('hello world')).toBe('hello world')
})
it('trims whitespace', () => {
expect(stripMentions(' @_user_1 hello ')).toBe('hello')
})
})
})
describe('Feishu: permission card', () => {
// Helpers to reach into Schema 2.0 body.elements
function getBodyElements(card: Record<string, unknown>): any[] {
return ((card.body as any).elements ?? []) as any[]
}
function getActionRow(card: Record<string, unknown>): any {
return getBodyElements(card).find((el) => el.tag === 'column_set')
}
function getButtons(card: Record<string, unknown>): any[] {
return getActionRow(card).columns.map(
(c: any) => c.elements.find((e: any) => e.tag === 'button'),
)
}
// ----- Schema 2.0 regression -----
it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
expect(card.schema).toBe('2.0')
expect(card.elements).toBeUndefined() // old bug had top-level elements
expect((card.body as any).elements).toBeDefined()
expect((card.config as any).update_multi).toBe(true)
expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
})
it('header has title, subtitle, template, icon', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
const header = card.header as any
expect(header.title.content).toContain('权限确认')
expect(header.subtitle.content).toBe('Bash')
expect(header.template).toBe('orange')
expect(header.icon.tag).toBe('standard_icon')
})
// ----- Three buttons -----
it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
const card = buildPermissionCard('Read', {}, 'xyz')
const [allow, always, deny] = getButtons(card)
expect(allow.text.content).toContain('允许')
expect(allow.type).toBe('primary')
expect(always.text.content).toContain('永久允许')
expect(always.type).toBe('default')
expect(deny.text.content).toContain('拒绝')
expect(deny.type).toBe('danger')
})
it('允许 button carries allowed=true and no rule', () => {
const card = buildPermissionCard('Read', {}, 'req-1')
const [allow] = getButtons(card)
expect(allow.value).toEqual({
action: 'permit',
requestId: 'req-1',
allowed: true,
})
expect(allow.value.rule).toBeUndefined()
})
it('永久允许 button carries allowed=true + rule=always', () => {
const card = buildPermissionCard('Read', {}, 'req-2')
const always = getButtons(card)[1]
expect(always.value).toEqual({
action: 'permit',
requestId: 'req-2',
allowed: true,
rule: 'always',
})
})
it('拒绝 button carries allowed=false and no rule', () => {
const card = buildPermissionCard('Read', {}, 'req-3')
const deny = getButtons(card)[2]
expect(deny.value).toEqual({
action: 'permit',
requestId: 'req-3',
allowed: false,
})
})
// ----- Tool summary rendering -----
it('renders Write with ✏️ 写入文件 header and file path target', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/output.txt', content: 'hi' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('✏️')
expect(elements[0].content).toContain('写入文件')
expect(elements[0].content).toContain('`Write`')
// Target rendered as fenced code block
expect(elements[1].content).toContain('/tmp/output.txt')
expect(elements[1].content.startsWith('```')).toBe(true)
})
it('renders Edit with ✏️ 修改文件', () => {
const card = buildPermissionCard(
'Edit',
{ file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
'req',
)
expect(getBodyElements(card)[0].content).toContain('修改文件')
})
it('renders Bash with 🖥️ 执行命令 and command target', () => {
const card = buildPermissionCard(
'Bash',
{ command: 'rm -rf /tmp/x' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🖥️')
expect(elements[0].content).toContain('执行命令')
expect(elements[1].content).toContain('rm -rf /tmp/x')
})
it('truncates very long Bash commands to 160 chars', () => {
const longCmd = 'echo ' + 'x'.repeat(500)
const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
const targetEl = getBodyElements(card)[1]
expect(targetEl.content).toContain('…')
// Fenced code wraps ~10 extra chars
expect(targetEl.content.length).toBeLessThanOrEqual(180)
})
it('renders Grep with 🔍 搜索内容 and pattern target', () => {
const card = buildPermissionCard(
'Grep',
{ pattern: 'TODO', path: '/src' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🔍')
expect(elements[1].content).toContain('TODO')
})
it('renders WebFetch with 🌐 访问网页 and url target', () => {
const card = buildPermissionCard(
'WebFetch',
{ url: 'https://example.com/api' },
'req',
)
const elements = getBodyElements(card)
expect(elements[0].content).toContain('🌐')
expect(elements[0].content).toContain('访问网页')
expect(elements[1].content).toContain('https://example.com/api')
})
it('falls back to 🔧 <toolName> for unknown tools', () => {
const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
expect(getBodyElements(card)[0].content).toContain('🔧')
expect(getBodyElements(card)[0].content).toContain('CustomTool')
})
it('has no target line when input is empty', () => {
const card = buildPermissionCard('Bash', {}, 'req')
const elements = getBodyElements(card)
// elements: [header_md, hr, action_column_set]
expect(elements[1].tag).toBe('hr')
})
// ----- Cross-directory detection -----
it('does NOT show cross-dir warning when file is inside workDir', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/Users/me/proj/src/a.ts' },
'req',
'/Users/me/proj',
)
const elements = getBodyElements(card)
const hasWarn = elements.some(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(hasWarn).toBe(false)
expect((card.header as any).template).toBe('orange')
expect((card.header as any).subtitle.content).toBe('Write')
})
it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/evil.sh' },
'req',
'/Users/me/proj',
)
const elements = getBodyElements(card)
const warn = elements.find(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(warn).toBeDefined()
expect((card.header as any).template).toBe('red')
expect((card.header as any).subtitle.content).toContain('跨目录')
})
it('does NOT check cross-dir for Bash (no filePath)', () => {
const card = buildPermissionCard(
'Bash',
{ command: 'rm -rf /tmp/x' },
'req',
'/Users/me/proj',
)
expect((card.header as any).template).toBe('orange')
})
it('does not warn when workDir is not provided', () => {
const card = buildPermissionCard(
'Write',
{ file_path: '/tmp/x.ts' },
'req',
// workDir omitted
)
const elements = getBodyElements(card)
const hasWarn = elements.some(
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
)
expect(hasWarn).toBe(false)
})
})
describe('Feishu: isOutsideWorkDir', () => {
it('returns false for file inside workDir', () => {
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
})
it('returns false for file directly in workDir', () => {
expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
})
it('returns true for file in a sibling directory', () => {
expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
})
it('returns true for /tmp file', () => {
expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
})
it('handles workDir with trailing slash', () => {
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
})
it('resolves relative paths against workDir', () => {
expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
})
it('does not match prefix collisions (proj vs proj2)', () => {
// /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
// but is NOT inside /Users/me/proj
expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
})
})
describe('Feishu: project picker card', () => {
const sampleProjects: RecentProject[] = [
{
projectPath: '/Users/dev/claude-code-haha',
realPath: '/Users/dev/claude-code-haha',
projectName: 'claude-code-haha',
isGit: true,
repoName: 'claude-code-haha',
branch: 'main',
modifiedAt: '2026-04-11T00:00:00Z',
sessionCount: 3,
},
{
projectPath: '/Users/dev/desktop',
realPath: '/Users/dev/desktop',
projectName: 'desktop',
isGit: false,
repoName: null,
branch: null,
modifiedAt: '2026-04-10T00:00:00Z',
sessionCount: 1,
},
]
function getBodyElements(card: Record<string, unknown>): any[] {
return ((card.body as any).elements ?? []) as any[]
}
function getRows(card: Record<string, unknown>): any[] {
return getBodyElements(card).filter((el) => el.tag === 'column_set')
}
function getRowButton(row: any): any {
const buttonCol = row.columns.find((c: any) =>
c.elements.some((e: any) => e.tag === 'button'),
)
return buttonCol.elements.find((e: any) => e.tag === 'button')
}
function getRowInfoElements(row: any): any[] {
const infoCol = row.columns.find((c: any) =>
c.elements.every((e: any) => e.tag === 'markdown'),
)
return infoCol.elements
}
it('uses Schema 2.0 with body.elements wrapper', () => {
const card = buildProjectPickerCard(sampleProjects)
expect(card.schema).toBe('2.0')
expect((card.config as any).update_multi).toBe(true)
expect((card.body as any).elements).toBeDefined()
})
it('header has title and project-count subtitle', () => {
const card = buildProjectPickerCard(sampleProjects)
expect((card.header as any).title.content).toContain('选择项目')
expect((card.header as any).subtitle.content).toContain('2')
expect((card.header as any).subtitle.content).toContain('最近项目')
})
it('subtitle notes truncation when more than 10 projects exist', () => {
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
...sampleProjects[0]!,
projectName: `proj-${i}`,
realPath: `/p/${i}`,
}))
const card = buildProjectPickerCard(many)
const subtitle = (card.header as any).subtitle.content
expect(subtitle).toContain('15')
expect(subtitle).toContain('显示前 10')
})
it('body contains one column_set row per project', () => {
const card = buildProjectPickerCard(sampleProjects)
expect(getRows(card).length).toBe(2)
})
it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
const card = buildProjectPickerCard(sampleProjects)
for (const row of getRows(card)) {
expect(row.columns.length).toBe(2)
expect(row.columns[0].width).toBe('weighted')
expect(row.columns[0].vertical_align).toBe('center')
expect(row.columns[1].width).toBe('auto')
expect(row.columns[1].vertical_align).toBe('center')
}
})
it('info column has title markdown + notation path markdown', () => {
const card = buildProjectPickerCard(sampleProjects)
const row1 = getRows(card)[0]
const info = getRowInfoElements(row1)
expect(info.length).toBe(2)
// Title markdown
expect(info[0].tag).toBe('markdown')
expect(info[0].content).toContain('**claude-code-haha**')
expect(info[0].content).toContain('*main*')
// Path markdown (notation = small grey)
expect(info[1].tag).toBe('markdown')
expect(info[1].text_size).toBe('notation')
expect(info[1].content).toContain('claude-code-haha')
})
it('row without branch has no separator dot in title', () => {
const card = buildProjectPickerCard(sampleProjects)
const row2 = getRows(card)[1]
const title = getRowInfoElements(row2)[0].content
expect(title).toContain('**desktop**')
expect(title).not.toContain('·')
})
it('row button says 选择 with small size and carries per-project value', () => {
const card = buildProjectPickerCard(sampleProjects)
const rows = getRows(card)
const btn1 = getRowButton(rows[0])
expect(btn1.text.content).toBe('选择')
expect(btn1.size).toBe('small')
expect(btn1.value.action).toBe('pick_project')
expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
expect(btn1.value.projectName).toBe('claude-code-haha')
const btn2 = getRowButton(rows[1])
expect(btn2.value.realPath).toBe('/Users/dev/desktop')
})
it('first row button is primary, rest are default', () => {
const card = buildProjectPickerCard(sampleProjects)
const rows = getRows(card)
expect(getRowButton(rows[0]).type).toBe('primary')
expect(getRowButton(rows[1]).type).toBe('default')
})
it('body tail has hr and notation footer hint', () => {
const card = buildProjectPickerCard(sampleProjects)
const elements = getBodyElements(card)
const hrIdx = elements.findIndex((el) => el.tag === 'hr')
expect(hrIdx).toBeGreaterThan(0)
expect(elements[hrIdx + 1].tag).toBe('markdown')
expect(elements[hrIdx + 1].text_size).toBe('notation')
})
it('caps to first 10 projects', () => {
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
...sampleProjects[0]!,
projectName: `proj-${i}`,
realPath: `/p/${i}`,
}))
const card = buildProjectPickerCard(many)
const rows = getRows(card)
expect(rows.length).toBe(10)
expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
})
it('uses ~ shortcut when path is under $HOME', () => {
const home = process.env.HOME
if (!home) return
const project: RecentProject = {
...sampleProjects[0]!,
realPath: `${home}/some/sub/dir`,
projectName: 'sub-dir',
}
const card = buildProjectPickerCard([project])
const pathEl = getRowInfoElements(getRows(card)[0])[1]
expect(pathEl.content).toBe('~/some/sub/dir')
})
it('middle-truncates very long paths with ellipsis', () => {
const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
const project: RecentProject = {
...sampleProjects[0]!,
realPath: veryLong,
projectName: 'project',
}
const card = buildProjectPickerCard([project])
const content = getRowInfoElements(getRows(card)[0])[1].content
expect(content).toContain('…')
expect(content.length).toBeLessThanOrEqual(56)
expect(content.endsWith('project')).toBe(true)
})
})
describe('Feishu: card.action.trigger parsing', () => {
it('parses permit action from event', () => {
const event = {
operator: { open_id: 'ou_user_1' },
action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
context: { open_chat_id: 'oc_chat_123' },
}
expect(event.action.value.action).toBe('permit')
expect(event.action.value.requestId).toBe('abcde')
expect(event.action.value.allowed).toBe(true)
expect(event.context.open_chat_id).toBe('oc_chat_123')
})
it('parses pick_project action from event', () => {
const event = {
operator: { open_id: 'ou_user_1' },
action: {
value: {
action: 'pick_project',
realPath: '/Users/dev/claude-code-haha',
projectName: 'claude-code-haha',
},
},
context: { open_chat_id: 'oc_chat_123' },
}
expect(event.action.value.action).toBe('pick_project')
expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
expect(event.action.value.projectName).toBe('claude-code-haha')
})
it('ignores non-handled actions', () => {
const event = {
action: { value: { action: 'other_action' } },
}
expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
})
})
@@ -0,0 +1,290 @@
/**
* FlushController 单元测试
*
* 覆盖:
* - 基础: cardMessageReady gate, complete() 锁死
* - 节流窗口: 立即 flush / 延迟 flush
* - Mutex: 进行中的 flush 重复调用标记 needsReflush
* - Conflict reflush: API 结束后自动补一次
* - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush
* - waitForFlush: 等当前 flush 结束
*/
import { describe, it, expect } from 'bun:test'
import { FlushController, THROTTLE } from '../flush-controller.js'
// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve
function makeControllableFlush() {
const calls: string[] = []
let resolveCurrent: (() => void) | null = null
let latch: Promise<void> | null = null
const doFlush = async () => {
calls.push('flush-start')
if (latch) {
await latch
latch = null
}
calls.push('flush-end')
}
const blockNext = () => {
latch = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
}
const unblock = () => {
if (resolveCurrent) {
const r = resolveCurrent
resolveCurrent = null
r()
}
}
return { doFlush, calls, blockNext, unblock }
}
async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms))
}
// ---------------------------------------------------------------------------
// Basic gating
// ---------------------------------------------------------------------------
describe('FlushController: cardMessageReady gate', () => {
it('在 cardMessageReady=false 时不 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
await fc.flush()
await fc.throttledUpdate(50)
expect(count).toBe(0)
})
it('setCardMessageReady(true) 后 flush 可执行', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
await fc.flush()
expect(count).toBe(1)
})
it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
// 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支
await fc.throttledUpdate(500)
// 同步阶段还没到 500ms,不应触发 flush
expect(count).toBe(0)
// 500+ms 后延迟 timer 触发
await sleep(600)
expect(count).toBe(1)
})
})
describe('FlushController: complete()', () => {
it('complete() 后拒绝新 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
fc.complete()
await fc.flush()
await fc.throttledUpdate(50)
expect(count).toBe(0)
})
})
// ---------------------------------------------------------------------------
// Throttle window
// ---------------------------------------------------------------------------
describe('FlushController: 节流窗口', () => {
it('超过窗口立即 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
// 手动把 lastUpdateTime 挪远(等同于已过了节流窗口)
await sleep(150)
await fc.throttledUpdate(100)
expect(count).toBe(1)
})
it('在窗口内首次调用安排延迟 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true) // lastUpdateTime = now
await fc.throttledUpdate(200)
expect(count).toBe(0) // 延迟中,还没触发
await sleep(300)
expect(count).toBe(1) // 200ms 后延迟 timer 触发
})
it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await sleep(300)
expect(count).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Mutex + conflict reflush
// ---------------------------------------------------------------------------
describe('FlushController: mutex + 冲突重刷', () => {
it('flush 进行中的重复调用不并发执行', async () => {
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush() // flush-start 后被 latch 卡住
// 让事件循环走一轮,确保第一次 flush 进入 body
await sleep(10)
// 第二次调用时第一次还没结束 —— 应被 mutex 挡住
const p2 = fc.flush()
// 两次 Promise 都已登记,但都还没 end
expect(calls).toEqual(['flush-start'])
unblock()
await p1
await p2
// 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷
// (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完)
await sleep(20)
// 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end
expect(calls).toEqual([
'flush-start', 'flush-end',
'flush-start', 'flush-end',
])
})
it('flush 进行中的 throttledUpdate 也会触发补刷', async () => {
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush()
await sleep(10)
// API 进行中收到新的 update 请求
await fc.throttledUpdate(10)
unblock()
await p1
await sleep(30)
// 第一次 flush + 冲突补刷
expect(calls.filter((c) => c === 'flush-end').length).toBe(2)
})
})
// ---------------------------------------------------------------------------
// Long gap batching
// ---------------------------------------------------------------------------
describe('FlushController: 长间隔批量', () => {
it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => {
let count = 0
let flushAtMs = 0
const start = Date.now()
const fc = new FlushController(async () => {
count += 1
flushAtMs = Date.now() - start
})
fc.setCardMessageReady(true)
// 等到 elapsed > 2000ms
await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50)
const callAt = Date.now() - start
await fc.throttledUpdate(THROTTLE.CARDKIT_MS)
// throttledUpdate 同步阶段不应立即 flush(因为走批量分支)
expect(count).toBe(0)
// 等 BATCH_AFTER_GAP_MS + 余量
await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50)
expect(count).toBe(1)
// 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms
expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20)
})
})
// ---------------------------------------------------------------------------
// waitForFlush
// ---------------------------------------------------------------------------
describe('FlushController: waitForFlush', () => {
it('没在 flush 时立即返回', async () => {
const fc = new FlushController(async () => {})
fc.setCardMessageReady(true)
const start = Date.now()
await fc.waitForFlush()
expect(Date.now() - start).toBeLessThan(10)
})
it('有 flush 在跑时等它结束', async () => {
const { doFlush, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush()
await sleep(10)
let resolved = false
const waiter = fc.waitForFlush().then(() => {
resolved = true
})
await sleep(20)
expect(resolved).toBe(false)
unblock()
await p1
await waiter
expect(resolved).toBe(true)
})
})
// ---------------------------------------------------------------------------
// 常量合理性
// ---------------------------------------------------------------------------
describe('FlushController: THROTTLE 常量', () => {
it('CARDKIT_MS=100, PATCH_MS=1500', () => {
expect(THROTTLE.CARDKIT_MS).toBe(100)
expect(THROTTLE.PATCH_MS).toBe(1500)
})
it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => {
expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000)
expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300)
})
})
@@ -0,0 +1,353 @@
/**
* markdown-style 单元测试
*
* 覆盖:
* - 标题降级 (H1~H3 workaround)
* - 代码块保护
* - 空行压缩
* - Schema 2.0: 连续标题/表格/代码块 <br> 间距
* - stripInvalidImageKeys
* - sanitizeTextForCard (表格数限制)
* - findMarkdownTablesOutsideCodeBlocks
*/
import { describe, it, expect } from 'bun:test'
import {
optimizeMarkdownForFeishu,
sanitizeTextForCard,
findMarkdownTablesOutsideCodeBlocks,
FEISHU_CARD_TABLE_LIMIT,
} from '../markdown-style.js'
// 默认 cardVersion=2 的 shortcut
const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v)
// ---------------------------------------------------------------------------
// 标题降级
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: 标题降级', () => {
it('H1 → H4 (cardVersion=1 简化检查)', () => {
expect(opt('# Title', 1)).toBe('#### Title')
})
it('H2 → H5 (cardVersion=1)', () => {
expect(opt('## Title', 1)).toBe('##### Title')
})
it('H3 → H5 (cardVersion=1)', () => {
expect(opt('### Title', 1)).toBe('##### Title')
})
it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => {
expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3')
})
it('纯 H4 文档不触发降级 (cardVersion=1)', () => {
// 触发条件: 原文必须有 H1~H3
expect(opt('#### Already H4', 1)).toBe('#### Already H4')
})
it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => {
expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub')
})
it('# 后必须有空格才算标题', () => {
expect(opt('#notaheading', 1)).toBe('#notaheading')
})
it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => {
// openclaw-lark 源码里的关键注释:顺序不能颠倒
expect(opt('# Top', 1)).toBe('#### Top')
})
it('无标题文本原样返回', () => {
expect(opt('just plain text', 1)).toBe('just plain text')
})
it('默认 cardVersion=2 下也能正确降级标题', () => {
const out = opt('# Title')
expect(out).toContain('#### Title')
expect(out).not.toMatch(/^# Title$/m)
})
})
// ---------------------------------------------------------------------------
// 代码块保护
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
it('代码块内的 # 不被降级 (cardVersion=1)', () => {
const input = '```\n# not a heading\n## also not\n```'
expect(opt(input, 1)).toBe(input)
})
it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => {
const input = '# Real heading\n\n```\n# inside code\n```'
expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```')
})
it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => {
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
})
it('多个代码块按顺序保护与还原 (cardVersion=1)', () => {
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
})
it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => {
const out = opt('# Heading\n\n```\n# inside\n```')
expect(out).toContain('#### Heading') // 外部 H1 降级
expect(out).toContain('# inside') // 代码块内保留
expect(out).toContain('```') // fence 保留
})
})
// ---------------------------------------------------------------------------
// 空行压缩
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
it('3 个换行 → 2 个 (cardVersion=1)', () => {
expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2')
})
it('5 个换行 → 2 个 (cardVersion=1)', () => {
expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2')
})
it('2 个换行保留 (cardVersion=1)', () => {
expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2')
})
it('代码块内部连续换行被保留 (cardVersion=1)', () => {
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
})
})
// ---------------------------------------------------------------------------
// Schema 2.0: <br> 间距
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
it('cardVersion=2 默认在代码块前后加 <br>', () => {
const out = opt('text\n\n```\ncode\n```')
// 代码块前后应包裹 <br>
expect(out).toContain('<br>\n```')
expect(out).toContain('```\n<br>')
})
it('cardVersion=1 代码块前后不加 <br>', () => {
const out = opt('text\n\n```\ncode\n```', 1)
expect(out).not.toContain('<br>')
})
it('cardVersion=2 连续标题之间加 <br>', () => {
const out = opt('# A\n# B')
// H1 降级为 H4,之间插入 <br>
expect(out).toMatch(/#### A\n<br>\n#### B/)
})
it('cardVersion=2 表格前后加 <br>', () => {
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
const out = opt(input)
// 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
expect(out).toMatch(/text before\n<br>\n\| col1/)
// 表格后: <br> 跟两个换行到下一段文本
expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
})
it('cardVersion=1 表格前后不加 <br>', () => {
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
const out = opt(input, 1)
expect(out).not.toContain('<br>')
})
it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => {
const input = '```\n| in code | not a table |\n|---|---|\n```'
const out = opt(input)
// 代码块本体应完整保留
expect(out).toContain('| in code | not a table |')
// 代码块外应该没有出现表格 br 标记(因为代码块内不算表格)
// 代码块本身会被 <br> 包裹(Schema 2.0)但不会在 | 周围单独加 <br>
expect(out).toMatch(/<br>\n```\n\| in code/)
})
})
// ---------------------------------------------------------------------------
// stripInvalidImageKeys
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => {
it('img_* 图片 key 保留', () => {
const out = opt('前缀 ![alt](img_abc123) 后缀')
expect(out).toContain('![alt](img_abc123)')
})
it('http:// URL 图片被删除', () => {
const out = opt('前缀 ![alt](http://example.com/img.png) 后缀')
expect(out).toBe('前缀 后缀')
})
it('https:// URL 图片被删除', () => {
const out = opt('![a](https://x.y/z.jpg)')
expect(out).toBe('')
})
it('本地路径被删除', () => {
const out = opt('![a](/Users/me/pic.png)')
expect(out).toBe('')
})
it('无图片文本原样', () => {
expect(opt('no images here', 1)).toBe('no images here')
})
it('混合: img_ 保留,URL 删除', () => {
const out = opt('![keep](img_good) 和 ![drop](http://bad.com/x.png)')
expect(out).toContain('![keep](img_good)')
expect(out).not.toContain('bad.com')
expect(out).not.toContain('![drop]')
})
})
// ---------------------------------------------------------------------------
// findMarkdownTablesOutsideCodeBlocks
// ---------------------------------------------------------------------------
describe('findMarkdownTablesOutsideCodeBlocks', () => {
it('识别单张表格', () => {
const text = '| a | b |\n|---|---|\n| 1 | 2 |'
const matches = findMarkdownTablesOutsideCodeBlocks(text)
expect(matches.length).toBe(1)
expect(matches[0]!.raw).toContain('| a | b |')
})
it('识别多张表格', () => {
const text =
'| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |'
const matches = findMarkdownTablesOutsideCodeBlocks(text)
expect(matches.length).toBe(2)
})
it('代码块内的 | 不被算作表格', () => {
const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```'
const matches = findMarkdownTablesOutsideCodeBlocks(text)
expect(matches.length).toBe(0)
})
it('代码块 + 外部表格: 只识别外部的', () => {
const text =
'```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |'
const matches = findMarkdownTablesOutsideCodeBlocks(text)
expect(matches.length).toBe(1)
expect(matches[0]!.raw).toContain('real')
})
it('无表格文本返回空数组', () => {
expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0)
})
})
// ---------------------------------------------------------------------------
// sanitizeTextForCard
// ---------------------------------------------------------------------------
describe('sanitizeTextForCard: 表格数量限制', () => {
function makeTable(label: string): string {
return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |`
}
it('表格数 ≤ 3 时原样返回', () => {
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
expect(sanitizeTextForCard(text)).toBe(text)
})
it('恰好 3 张表格原样返回', () => {
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
const matches = findMarkdownTablesOutsideCodeBlocks(text)
expect(matches.length).toBe(3)
expect(sanitizeTextForCard(text)).toBe(text)
})
it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => {
const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n')
const out = sanitizeTextForCard(text)
// 前 3 张表格原样
expect(out).toContain(makeTable('A'))
expect(out).toContain(makeTable('B'))
expect(out).toContain(makeTable('C'))
// 第 4 张被包裹
expect(out).toContain('```\n' + makeTable('D') + '\n```')
})
it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => {
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
const out = sanitizeTextForCard(text, 1)
expect(out).toContain(makeTable('A'))
expect(out).toContain('```\n' + makeTable('B') + '\n```')
expect(out).toContain('```\n' + makeTable('C') + '\n```')
})
it('limit=0: 全部包裹', () => {
const text = makeTable('Solo')
const out = sanitizeTextForCard(text, 0)
expect(out).toContain('```\n' + makeTable('Solo') + '\n```')
})
it('无表格原样返回', () => {
expect(sanitizeTextForCard('no tables here')).toBe('no tables here')
})
it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => {
expect(FEISHU_CARD_TABLE_LIMIT).toBe(3)
})
})
// ---------------------------------------------------------------------------
// 边界与真实场景
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: 边界与真实场景', () => {
it('screenshot 里的 OpenCutSkill 项目结构报告', () => {
const input = `## OpenCutSkill 项目架构概览
### 1. 项目定位
Screen Studio 视频自动剪辑工具。
### 2. 模块结构
\`\`\`
opencutskill/
├── cli/
├── core/
└── tests/
\`\`\``
const out = opt(input)
// 所有 H2~H3 应被降级为 H5
expect(out).toContain('##### OpenCutSkill 项目架构概览')
expect(out).toContain('##### 1. 项目定位')
expect(out).toContain('##### 2. 模块结构')
// 代码块内容原封不动
expect(out).toContain('opencutskill/')
expect(out).toContain('├── cli/')
// 原始 ## 字面量不残留
expect(out).not.toMatch(/^## OpenCutSkill/m)
expect(out).not.toMatch(/^### 1\./m)
// 代码块前后有 <br>(Schema 2.0 默认)
expect(out).toContain('<br>\n```')
expect(out).toContain('```\n<br>')
})
it('异常输入 fallback 到原文不抛错', () => {
expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow()
})
it('空字符串返回空字符串', () => {
expect(opt('')).toBe('')
})
})
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { FeishuMediaService } from '../media.js'
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
function makeMockClient() {
return {
im: {
messageResource: {
get: mock(async () => ({
// node-sdk returns an object with a `.writeFile(path)` helper
// that dumps the underlying stream. We fake that here.
writeFile: async (target: string) => {
await fs.writeFile(target, Buffer.from('DOWNLOADED'))
},
})),
},
image: {
create: mock(async (_req: any) => ({
data: { image_key: 'img_fake_123' },
})),
},
file: {
create: mock(async (_req: any) => ({
data: { file_key: 'file_fake_456' },
})),
},
message: {
create: mock(async (_req: any) => ({
data: { message_id: 'om_fake' },
})),
},
},
}
}
let tmpRoot: string
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-'))
})
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true })
})
describe('FeishuMediaService', () => {
it('downloadResource writes a local file and returns LocalAttachment', async () => {
const client = makeMockClient()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new FeishuMediaService(client as any, store)
const local = await svc.downloadResource({
messageId: 'om_msg_1',
fileKey: 'img_key_1',
kind: 'image',
fileName: 'cat.png',
sessionId: 'sess-1',
})
expect(local.kind).toBe('image')
expect(local.name).toBe('cat.png')
expect(local.size).toBe('DOWNLOADED'.length)
expect(local.path).toContain(path.join('feishu', 'sess-1'))
const onDisk = await fs.readFile(local.path)
expect(onDisk.toString()).toBe('DOWNLOADED')
expect(client.im.messageResource.get).toHaveBeenCalledTimes(1)
const call = (client.im.messageResource.get as any).mock.calls[0][0]
expect(call.path.message_id).toBe('om_msg_1')
expect(call.path.file_key).toBe('img_key_1')
expect(call.params.type).toBe('image')
})
it('uploadImage returns an image_key and sends the buffer through', async () => {
const client = makeMockClient()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new FeishuMediaService(client as any, store)
const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png')
expect(key).toBe('img_fake_123')
expect(client.im.image.create).toHaveBeenCalledTimes(1)
const call = (client.im.image.create as any).mock.calls[0][0]
expect(call.data.image_type).toBe('message')
expect(call.data.image).toBeDefined()
})
it('uploadFile returns a file_key and uses stream file_type mapping', async () => {
const client = makeMockClient()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new FeishuMediaService(client as any, store)
const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf')
expect(key).toBe('file_fake_456')
const call = (client.im.file.create as any).mock.calls[0][0]
expect(call.data.file_name).toBe('report.pdf')
expect(call.data.file_type).toBe('pdf')
})
it('sendImageMessage posts msg_type=image', async () => {
const client = makeMockClient()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new FeishuMediaService(client as any, store)
await svc.sendImageMessage('oc_chat_1', 'img_fake_123')
const call = (client.im.message.create as any).mock.calls[0][0]
expect(call.params.receive_id_type).toBe('chat_id')
expect(call.data.receive_id).toBe('oc_chat_1')
expect(call.data.msg_type).toBe('image')
const content = JSON.parse(call.data.content)
expect(content.image_key).toBe('img_fake_123')
})
it('sendFileMessage posts msg_type=file', async () => {
const client = makeMockClient()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new FeishuMediaService(client as any, store)
await svc.sendFileMessage('oc_chat_1', 'file_fake_456')
const call = (client.im.message.create as any).mock.calls[0][0]
expect(call.data.msg_type).toBe('file')
const content = JSON.parse(call.data.content)
expect(content.file_key).toBe('file_fake_456')
})
})
@@ -0,0 +1,914 @@
/**
* StreamingCard 生命周期测试
*
* 用 mock Lark client 覆盖:
* - ensureCreated: 成功路径 / 降级路径
* - appendText: 累积 + 触发 throttled flush
* - finalize: settings(false) + update 顺序、sequence 单调递增
* - abort: 渲染错误卡片
* - 230020 → 跳帧
* - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
* - 纯 patch fallback 路径
*/
import { describe, it, expect, beforeEach } from 'bun:test'
import {
StreamingCard,
buildInitialStreamingCard,
buildRenderedCard,
buildErrorCard,
} from '../streaming-card.js'
import { STREAMING_ELEMENT_ID } from '../cardkit.js'
// ---------------------------------------------------------------------------
// Mock client
// ---------------------------------------------------------------------------
type ApiCall = { api: string; args: any }
type MockBehavior = {
'card.create'?: any | ((args: any) => any)
'card.settings'?: any | ((args: any) => any)
'card.update'?: any | ((args: any) => any)
'cardElement.content'?: any | ((args: any, callIdx: number) => any)
'im.message.create'?: any | ((args: any) => any)
'im.message.reply'?: any | ((args: any) => any)
'im.message.patch'?: any | ((args: any, callIdx: number) => any)
}
function makeMockClient(behavior: MockBehavior = {}) {
const calls: ApiCall[] = []
let contentCallIdx = 0
let patchCallIdx = 0
function handle(api: string, resp: any, args: any, idx?: number): any {
calls.push({ api, args })
if (typeof resp === 'function') return resp(args, idx ?? 0)
return resp
}
const client: any = {
cardkit: {
v1: {
card: {
create: async (args: any) =>
handle('cardkit.v1.card.create', behavior['card.create'] ?? {
code: 0, data: { card_id: 'ck_default' },
}, args),
settings: async (args: any) =>
handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
update: async (args: any) =>
handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
},
cardElement: {
content: async (args: any) => {
const idx = contentCallIdx++
return handle('cardkit.v1.cardElement.content',
behavior['cardElement.content'] ?? { code: 0 }, args, idx)
},
},
},
},
im: {
message: {
create: async (args: any) =>
handle('im.message.create', behavior['im.message.create'] ?? {
data: { message_id: 'om_default' },
}, args),
reply: async (args: any) =>
handle('im.message.reply', behavior['im.message.reply'] ?? {
data: { message_id: 'om_reply_default' },
}, args),
patch: async (args: any) => {
const idx = patchCallIdx++
return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
},
},
},
}
return { client, calls }
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms))
}
// ---------------------------------------------------------------------------
// Card JSON builders
// ---------------------------------------------------------------------------
describe('buildInitialStreamingCard', () => {
it('Schema 2.0 + streaming_mode + element_id', () => {
const card = buildInitialStreamingCard() as any
expect(card.schema).toBe('2.0')
expect(card.config.streaming_mode).toBe(true)
// 唯一元素:streaming_content,初始内容为 loading 提示
const elements = card.body.elements as any[]
expect(elements.length).toBe(1)
const streaming = elements[0]
expect(streaming.tag).toBe('markdown')
expect(streaming.content).toContain('正在思考中')
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
})
})
describe('buildRenderedCard', () => {
it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
const card = buildRenderedCard('hello world') as any
expect(card.schema).toBe('2.0')
expect(card.config.streaming_mode).toBeUndefined()
expect(card.body.elements.length).toBe(1)
const el = card.body.elements[0]
expect(el.tag).toBe('markdown')
expect(el.content).toBe('hello world')
// 最终卡无需 element_id
expect(el.element_id).toBeUndefined()
})
it('空字符串保底为单空格', () => {
const card = buildRenderedCard('') as any
expect(card.body.elements[0].content).toBe(' ')
})
})
describe('buildErrorCard', () => {
it('红色 header + markdown body', () => {
const card = buildErrorCard('oops') as any
expect((card.header as any).template).toBe('red')
expect((card.header as any).title.content).toContain('出错')
expect(card.body.elements[0].content).toBe('oops')
})
})
// ---------------------------------------------------------------------------
// StreamingCard lifecycle
// ---------------------------------------------------------------------------
describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
it('依次调用 card.create + im.message.create,sequence=1', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
'im.message.create': { data: { message_id: 'om_main_1' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBe('ck_main_1')
expect(sc._getMessageId()).toBe('om_main_1')
expect(sc._getSequence()).toBe(1)
expect(sc._isCardKitStreamActive()).toBe(true)
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
expect(calls[1]!.api).toBe('im.message.create')
// 初始卡 JSON 包含 streaming_mode 和 element_id
const cardJson = JSON.parse(calls[0]!.args.data.data)
expect(cardJson.schema).toBe('2.0')
expect(cardJson.config.streaming_mode).toBe(true)
// 唯一元素即 streaming_content
expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
// IM message 引用 card_id
const content = JSON.parse(calls[1]!.args.data.content)
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
})
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_1' } },
'im.message.create': { data: { message_id: 'om_1' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
await sc.ensureCreated()
await sc.ensureCreated()
// 只一次 create + 一次 send
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
expect(createCalls.length).toBe(1)
expect(sendCalls.length).toBe(1)
})
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.reply': { data: { message_id: 'om_reply' } },
})
const sc = new StreamingCard({
larkClient: client,
chatId: 'c',
replyToMessageId: 'om_parent',
})
await sc.ensureCreated()
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
expect(sc._getMessageId()).toBe('om_reply')
})
})
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 99991672, msg: 'permission denied' },
'im.message.create': { data: { message_id: 'om_fb' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBeNull()
expect(sc._getMessageId()).toBe('om_fb')
expect(sc._isCardKitStreamActive()).toBe(false)
// fallback 发送的是 Schema 2.0 interactive 卡
const createCall = calls.find((c) => c.api === 'im.message.create')
expect(createCall).toBeDefined()
expect(createCall!.args.data.msg_type).toBe('interactive')
const cardContent = JSON.parse(createCall!.args.data.content)
expect(cardContent.schema).toBe('2.0')
})
it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
let sendCallCount = 0
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': () => {
sendCallCount++
if (sendCallCount === 1) throw new Error('send failed')
return { data: { message_id: 'om_fb2' } }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBeNull()
expect(sc._getMessageId()).toBe('om_fb2')
})
it('降级发送也失败 → aborted + throw', async () => {
const { client } = makeMockClient({
'card.create': { code: 99991672 },
'im.message.create': () => {
throw new Error('really broken')
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await expect(sc.ensureCreated()).rejects.toThrow()
expect(sc._getPhase()).toBe('aborted')
})
})
// ---------------------------------------------------------------------------
// appendText + flush
// ---------------------------------------------------------------------------
describe('StreamingCard: appendText + flush', () => {
it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
sc.appendText('Hello ')
sc.appendText('world')
// 节流窗口 100ms + 余量
await sleep(150)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThan(0)
// 最后一次 flush 的内容应包含完整累积文本
const lastCall = contentCalls[contentCalls.length - 1]!
expect(lastCall.args.data.content).toContain('Hello world')
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
// sequence 严格单调递增
const seqs = contentCalls.map((c) => c.args.data.sequence)
for (let i = 1; i < seqs.length; i++) {
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
}
})
it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('same')
await sleep(150)
// 强制再跑一次 flush(无新文本)
await sc._getFlushController().flush()
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
// 应该只有一次 content 调用
expect(contentCalls.length).toBe(1)
})
it('completed 之后的 appendText 被忽略', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
await sc.finalize()
sc.appendText('ignored')
expect(sc._getAccumulatedText()).toBe('')
})
})
// ---------------------------------------------------------------------------
// finalize
// ---------------------------------------------------------------------------
describe('StreamingCard: finalize', () => {
it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_final' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('# Title\n\nBody')
await sleep(150)
const contentSeqs = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.map((c) => c.args.data.sequence)
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
expect(settingsCalls.length).toBe(1)
expect(updateCalls.length).toBe(1)
const settingsSeq = settingsCalls[0]!.args.data.sequence
const updateSeq = updateCalls[0]!.args.data.sequence
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
expect(updateSeq).toBeGreaterThan(settingsSeq)
// settings 关闭 streaming_mode
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
expect(settings.streaming_mode).toBe(false)
// update 卡内容是预处理后的 markdown
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
const finalContent = finalCardJson.body.elements[0].content
// H1 被降级为 H4
expect(finalContent).toContain('#### Title')
expect(finalContent).toContain('Body')
})
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 99991672 },
'im.message.create': { data: { message_id: 'om_fb' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('## Heading\n\nContent')
await sleep(1600) // 等 PATCH_MS 窗口
await sc.finalize()
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
expect(patchCalls.length).toBeGreaterThan(0)
// 最后一次 patch 是 finalize 的(full final card)
const lastPatch = patchCalls[patchCalls.length - 1]!
const finalCard = JSON.parse(lastPatch.args.data.content)
const finalContent = finalCard.body.elements[0].content
// ## → ##### 降级
expect(finalContent).toContain('##### Heading')
})
it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
const { client } = makeMockClient()
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
})
it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_term' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 同时塞入三种内容
sc.appendReasoning('Let me think about this problem carefully...')
sc.startTool('tu_1', 'Read')
sc.completeTool('tu_1', 'Read')
sc.appendText('## 答复\n\n这是最终答复正文。')
await sleep(150)
// 流式中间帧应该包含 reasoning + tools + answer 全套
const lastMidFrame = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastMidFrame).toContain('思考中')
expect(lastMidFrame).toContain('Read')
expect(lastMidFrame).toContain('最终答复正文')
await sc.finalize()
// finalize 用的是 card.update,把整张卡换成只有 answer 的版本
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
const finalCardJson = JSON.parse(updateCall.args.data.card.data)
const finalContent = finalCardJson.body.elements[0].content as string
expect(finalContent).toContain('最终答复正文')
// H2 → 降级 H5
expect(finalContent).toContain('##### 答复')
// reasoning + tools 都不应该出现在终态
expect(finalContent).not.toContain('思考中')
expect(finalContent).not.toContain('think about this problem')
expect(finalContent).not.toContain('Read')
expect(finalContent).not.toContain('🛠️')
expect(finalContent).not.toContain('💭')
})
it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 只有推理,没有 appendText —— 异常 case 但要可控降级
sc.appendReasoning('I was thinking but never produced an answer.')
await sleep(150)
await sc.finalize()
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
// 至少能看到推理内容
expect(finalContent).toContain('thinking')
})
it('finalize 失败不抛出', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
'card.settings': () => {
throw new Error('settings exploded')
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('text')
await sleep(150)
// finalize 内部捕获错误不 rethrow
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
})
})
// ---------------------------------------------------------------------------
// Rate limit + table limit
// ---------------------------------------------------------------------------
describe('StreamingCard: 错误处理', () => {
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
let callIdx = 0
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
const i = callIdx++
if (i === 0) {
const err: any = new Error('rate limit')
err.code = 230020
throw err
}
return { code: 0 }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('first')
await sleep(150)
// 第一次被限流
sc.appendText(' second')
await sleep(150)
// 第二次应能成功
// CardKit 仍然 active(没降级)
expect(sc._isCardKitStreamActive()).toBe(true)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
})
it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
const err: any = new Error('content failed')
err.code = 230099
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
throw err
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('some content')
await sleep(150)
expect(sc._isCardKitStreamActive()).toBe(false)
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
await sc.finalize()
// finalize 仍然走 CardKit 的 settings + update(cardId 还在)
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
// 不走 patch
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// abort
// ---------------------------------------------------------------------------
describe('StreamingCard: abort', () => {
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_err' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('partial...')
await sleep(150)
await sc.abort(new Error('something went wrong'))
expect(sc._getPhase()).toBe('aborted')
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
expect(updateCalls.length).toBeGreaterThan(0)
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
expect(errCard.header.template).toBe('red')
expect(errCard.body.elements[0].content).toContain('something went wrong')
// 保留已累积的部分文本
expect(errCard.body.elements[0].content).toContain('partial...')
})
it('idle 阶段 abort 不抛错', async () => {
const { client } = makeMockClient()
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.abort(new Error('before any card'))
expect(sc._getPhase()).toBe('aborted')
})
})
// ---------------------------------------------------------------------------
// Reasoning / tool use rendering
// ---------------------------------------------------------------------------
describe('StreamingCard: appendReasoning', () => {
it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_think' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('Analyzing the problem. ')
sc.appendReasoning('Let me check file A.')
await sleep(150)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThan(0)
const last = contentCalls[contentCalls.length - 1]!
expect(last.args.data.content).toContain('💭')
expect(last.args.data.content).toContain('思考中')
expect(last.args.data.content).toContain('Analyzing the problem.')
expect(last.args.data.content).toContain('Let me check file A.')
// 没有 blockquote `>` 前缀 —— 这是新格式的关键
expect(last.args.data.content).not.toContain('> Analyzing')
// 没有 appendText → 不应有普通正文
expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
expect(sc._getAccumulatedText()).toBe('')
})
it('completed 之后 appendReasoning 被忽略', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
await sc.finalize()
sc.appendReasoning('too late')
expect(sc._getAccumulatedReasoning()).toBe('')
})
})
describe('StreamingCard: startTool / completeTool', () => {
it('startTool 压入 running 步骤,completeTool 翻到 done', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_tool' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool('tu_1', 'Read')
await sleep(150)
let steps = sc._getToolSteps()
expect(steps.length).toBe(1)
expect(steps[0]!.name).toBe('Read')
expect(steps[0]!.status).toBe('running')
// 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式)
const runningContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.map((c) => c.args.data.content)
.join('\n')
expect(runningContent).toContain('⚙️')
expect(runningContent).toContain('Read')
expect(runningContent).toContain('🛠️')
sc.completeTool('tu_1', 'Read')
await sleep(150)
steps = sc._getToolSteps()
expect(steps[0]!.status).toBe('done')
// 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
const lastContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastContent).toContain('✅')
expect(lastContent).toContain('Read')
// 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
expect(lastContent).not.toContain('⚙️')
})
it('按 toolUseId 去重: 同一 id 不重复压入', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool('tu_1', 'Read')
sc.startTool('tu_1', 'Read')
sc.startTool('tu_1', 'Read')
expect(sc._getToolSteps().length).toBe(1)
})
it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool(undefined, 'Read')
sc.startTool(undefined, 'Read')
// 合成 id 不同 → 两个独立步骤
expect(sc._getToolSteps().length).toBe(2)
})
it('completeTool 只匹配最近的 running 同名步骤', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool('tu_1', 'Bash')
sc.startTool('tu_2', 'Bash')
sc.completeTool(undefined, 'Bash')
const steps = sc._getToolSteps()
// 更晚的 tu_2 被标记 done
expect(steps[0]!.status).toBe('running')
expect(steps[1]!.status).toBe('done')
})
it('空 toolName 忽略', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool('tu_1', undefined)
sc.startTool('tu_1', '')
expect(sc._getToolSteps().length).toBe(0)
})
})
// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
// gate / 等任何东西吃掉)。
describe('StreamingCard: 真实事件流(用户场景回归)', () => {
it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_real' } },
'im.message.create': { data: { message_id: 'om_real' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
// 1. 用户发消息 → handleMessage 预建卡(fire-and-forget)
const creating = sc.ensureCreated()
await creating // 等卡可写
// 2. 服务端: status streaming + content_start{text} (thinking block)
// feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op)
// (no direct call here — 等同于 no-op)
// 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式)
sc.appendReasoning('Analyzing the latest commits to find ')
await sleep(30)
sc.appendReasoning('breaking changes. Need to look at ')
await sleep(30)
sc.appendReasoning('the public API surface, the schema files, ')
await sleep(30)
sc.appendReasoning('and any removed exports. Let me check the ')
await sleep(30)
sc.appendReasoning('git log first.')
// 等节流窗口结束
await sleep(200)
const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
expect(flushesAfterReasoning).toBeGreaterThan(0)
const lastReasoningContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
// 应该包含 reasoning 累积内容
expect(lastReasoningContent).toContain('breaking changes')
expect(lastReasoningContent).toContain('git log first')
// 4. 服务端: content_start{tool_use, name: 'Bash'}
sc.startTool('tu_bash_1', 'Bash')
await sleep(150)
const lastWithTool = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastWithTool).toContain('Bash')
expect(lastWithTool).toContain('⚙️')
expect(lastWithTool).toContain('🛠️')
// 5. 服务端: tool_use_complete
sc.completeTool('tu_bash_1', 'Bash')
await sleep(150)
const lastAfterToolDone = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastAfterToolDone).toContain('Bash')
// ⚙️ 切到 ✅ —— 当前唯一一步已完成
expect(lastAfterToolDone).toContain('✅')
expect(lastAfterToolDone).not.toContain('⚙️')
// 6. 第二个 tool 序列
sc.startTool('tu_read_1', 'Read')
await sleep(150)
sc.completeTool('tu_read_1', 'Read')
await sleep(150)
// 7. 最终 text 输出
sc.appendText('## 破坏性变更分析\n\n')
await sleep(120)
sc.appendText('1. **API 重命名**: foo → bar\n')
await sleep(120)
sc.appendText('2. **删除导出**: baz')
await sleep(200)
const lastWithText = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
// 应该同时包含 reasoning, tools, answer
expect(lastWithText).toContain('git log first') // reasoning
expect(lastWithText).toContain('Bash') // tool
expect(lastWithText).toContain('Read') // tool
expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
expect(lastWithText).toContain('API 重命名')
// 8. message_complete → finalize
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
// 验证有 settings + update 收尾
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
})
it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
let firstFrameRejected = false
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_recover' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
if (!firstFrameRejected) {
firstFrameRejected = true
// 模拟一个 *非* rate-limit、*非* table-limit 错误
// 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题
const err: any = new Error('mystery cardkit error')
err.code = 999999
throw err
}
return { code: 0 }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('first thought')
await sleep(150)
// 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
sc.appendReasoning(' second thought')
await sleep(150)
// 验证: 至少尝试了 2 次 cardElement.content 调用
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
// 而且 streaming 仍是 active
expect(sc._isCardKitStreamActive()).toBe(true)
})
})
describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_all' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('Should I read file A first?')
sc.startTool('tu_1', 'Read')
sc.appendText('Here is the answer.')
await sleep(150)
const lastContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
const idxTools = lastContent.indexOf('🛠️')
const idxReasoning = lastContent.indexOf('思考中')
const idxAnswer = lastContent.indexOf('Here is the answer')
expect(idxTools).toBeGreaterThan(-1)
expect(idxReasoning).toBeGreaterThan(-1)
expect(idxAnswer).toBeGreaterThan(-1)
// tools 在最顶部 → reasoning 居中 → answer 在底部
expect(idxTools).toBeLessThan(idxReasoning)
expect(idxReasoning).toBeLessThan(idxAnswer)
})
it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
let resolveCreate: (() => void) | null = null
const createLatch = new Promise<void>((r) => { resolveCreate = r })
const { client, calls } = makeMockClient({
'card.create': async () => {
await createLatch
return { code: 0, data: { card_id: 'ck_slow' } }
},
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
// 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
const creating = sc.ensureCreated()
// 让事件循环推进到 create 被 await
await sleep(10)
sc.startTool('tu_1', 'Glob')
// 此时 cardMessageReady 仍是 false —— 没有任何 flush
const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentBefore.length).toBe(0)
// 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
resolveCreate!()
await creating
await sleep(150)
const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentAfter.length).toBeGreaterThan(0)
const last = contentAfter[contentAfter.length - 1]!
expect(last.args.data.content).toContain('Glob')
expect(last.args.data.content).toContain('🛠️')
})
})
@@ -0,0 +1,151 @@
/**
* Feishu CardKit API 错误码解析与谓词
*
* 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
*
* Lark SDK 抛出的错误对象结构有多种:
* - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
* - Axios 风格: error.response.data.{code, msg}
* - data.code 嵌套(某些包装层)
*
* 此模块把这些统一成 { code, subCode, errMsg } 结构,
* 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
*/
// ---------------------------------------------------------------------------
// Error code constants
// ---------------------------------------------------------------------------
/** 卡片 API 级别错误码。 */
export const CARD_ERROR = {
/** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
RATE_LIMITED: 230020,
/** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
CARD_CONTENT_FAILED: 230099,
} as const
/**
* 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
* 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
*/
export const CARD_CONTENT_SUB_ERROR = {
/** 卡片元素(表格等)数量超限 */
ELEMENT_LIMIT: 11310,
} as const
// ---------------------------------------------------------------------------
// Code extraction
// ---------------------------------------------------------------------------
function coerceCode(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
/**
* 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
* - `{ code }` (SDK 直接挂载)
* - `{ data: { code } }` (响应体嵌套)
* - `{ response: { data: { code } } }` (Axios 风格)
*/
export function extractLarkApiCode(err: unknown): number | undefined {
if (!err || typeof err !== 'object') return undefined
const e = err as {
code?: unknown
data?: { code?: unknown }
response?: { data?: { code?: unknown } }
}
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
}
// ---------------------------------------------------------------------------
// Sub-error extraction
// ---------------------------------------------------------------------------
/**
* 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
*
* 示例输入:
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
* 返回: 11310
*/
export function extractSubCode(msg: string): number | null {
const match = /ErrCode:\s*(\d+)/.exec(msg)
if (!match) return null
const code = Number(match[1])
return Number.isFinite(code) ? code : null
}
// ---------------------------------------------------------------------------
// Structured error parsing
// ---------------------------------------------------------------------------
export type CardApiErrorInfo = {
code: number
subCode: number | null
errMsg: string
}
/**
* 从任意抛错对象中解析卡片 API 错误结构。
*
* 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
*/
export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
const code = extractLarkApiCode(err)
if (code === undefined) return null
// 按优先级提取 msg 文本
let errMsg = ''
if (err && typeof err === 'object') {
const e = err as {
msg?: unknown
message?: unknown
response?: { data?: { msg?: unknown } }
}
if (typeof e.msg === 'string') {
errMsg = e.msg
} else if (typeof e.response?.data?.msg === 'string') {
errMsg = e.response.data.msg
} else if (typeof e.message === 'string') {
errMsg = e.message
}
}
const subCode = extractSubCode(errMsg)
return { code, subCode, errMsg }
}
// ---------------------------------------------------------------------------
// Helper predicates
// ---------------------------------------------------------------------------
/** 判断错误是否为卡片发送频率限制(230020)。 */
export function isCardRateLimitError(err: unknown): boolean {
const parsed = parseCardApiError(err)
if (!parsed) return false
return parsed.code === CARD_ERROR.RATE_LIMITED
}
/**
* 判断错误是否为卡片表格数超限。
*
* 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
* (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
*
* 实际生产错误格式(openclaw-lark 2026-03 实测):
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
*/
export function isCardTableLimitError(err: unknown): boolean {
const parsed = parseCardApiError(err)
if (!parsed) return false
return (
parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
/table number over limit/i.test(parsed.errMsg)
)
}
@@ -0,0 +1,294 @@
/**
* 飞书 CardKit API 薄封装
*
* 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。
*
* 五步流程:
* 1. createCardEntity() —— 创建卡片实体,返回 card_id
* 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
* 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
* 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
* 5. updateCardKitCard() —— 全量替换卡片为最终态
*
* 关键约束:
* - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝
* - streamCardContent 传的是**完整累计文本**,不是 delta
* - 必须关闭 streaming_mode 后卡片才能被用户交互
*
* 参考实现: openclaw-lark/src/card/cardkit.ts
*/
import type * as Lark from '@larksuiteoapi/node-sdk'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
* `cardElement.content()` 更新的那一个 markdown 元素。 */
export const STREAMING_ELEMENT_ID = 'streaming_content'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* SDK 返回的通用响应结构。
* SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
* 我们统一当成 CardKitResponse 处理以免到处 `as any`。
*/
type CardKitResponse = {
code?: number
msg?: string
data?: Record<string, unknown>
[key: string]: unknown
}
/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
* 可被 card-errors.ts 的 parseCardApiError 识别。 */
export class CardKitApiError extends Error {
readonly code: number
readonly msg: string
constructor(params: { api: string; code: number; msg: string; context: string }) {
const { api, code, msg, context } = params
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
this.name = 'CardKitApiError'
this.code = code
this.msg = msg
}
}
type LarkClient = Lark.Client
// ---------------------------------------------------------------------------
// Response check
// ---------------------------------------------------------------------------
/**
* 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
*
* Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
* 判断是速率限制还是真错误。
*/
function assertCardKitOk(params: {
resp: CardKitResponse
api: string
context: string
}): void {
const { resp, api, context } = params
const code = resp.code
if (code !== undefined && code !== 0) {
throw new CardKitApiError({
api,
code,
msg: typeof resp.msg === 'string' ? resp.msg : '',
context,
})
}
}
// ---------------------------------------------------------------------------
// Step 1 — createCardEntity
// ---------------------------------------------------------------------------
/**
* 创建一张 CardKit 卡片实体,返回 card_id。
*
* 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
*
* @param client Lark SDK client
* @param card Schema 2.0 格式的卡片 JSON
* @returns 飞书分配的 card_id(失败时抛错)
*/
export async function createCardEntity(
client: LarkClient,
card: Record<string, unknown>,
): Promise<string> {
// SDK 返回类型不完整,cast 到运行时实际结构
const resp = (await client.cardkit.v1.card.create({
data: {
type: 'card_json',
data: JSON.stringify(card),
},
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.create',
context: `cardLen=${JSON.stringify(card).length}`,
})
// 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id
const cardId =
(resp.data?.card_id as string | undefined) ??
(resp.card_id as string | undefined)
if (!cardId) {
throw new CardKitApiError({
api: 'card.create',
code: resp.code ?? -1,
msg: 'response missing card_id',
context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
})
}
return cardId
}
// ---------------------------------------------------------------------------
// Step 2 — sendCardAsMessage
// ---------------------------------------------------------------------------
/**
* 把 CardKit 卡片通过 IM 消息挂到聊天窗。
*
* content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
* msg_type 固定为 `interactive`。
*
* @param client Lark SDK client
* @param chatId 目标 chat_id
* @param cardId CardKit card_id(由 createCardEntity 产生)
* @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create
* @returns 飞书分配的 message_id
*/
export async function sendCardAsMessage(
client: LarkClient,
chatId: string,
cardId: string,
replyToMessageId?: string,
): Promise<string> {
const content = JSON.stringify({
type: 'card',
data: { card_id: cardId },
})
if (replyToMessageId) {
const resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'interactive' },
})
const messageId = resp.data?.message_id
if (!messageId) {
throw new CardKitApiError({
api: 'im.message.reply',
code: -1,
msg: 'response missing message_id',
context: `cardId=${cardId}`,
})
}
return messageId
}
const resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'interactive',
content,
},
})
const messageId = resp.data?.message_id
if (!messageId) {
throw new CardKitApiError({
api: 'im.message.create',
code: -1,
msg: 'response missing message_id',
context: `chatId=${chatId} cardId=${cardId}`,
})
}
return messageId
}
// ---------------------------------------------------------------------------
// Step 3 — streamCardContent
// ---------------------------------------------------------------------------
/**
* 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端
* 渲染打字机效果。
*
* **重要**: `content` 必须传**完整累计文本**,不是 delta。
* sequence 必须**单调递增**,否则飞书拒绝。
*
* @param client Lark SDK client
* @param cardId CardKit card_id
* @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID)
* @param content 完整累计文本
* @param sequence 单调递增序列号
*/
export async function streamCardContent(
client: LarkClient,
cardId: string,
elementId: string,
content: string,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.cardElement.content({
data: { content, sequence },
path: { card_id: cardId, element_id: elementId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'cardElement.content',
context: `seq=${sequence} len=${content.length}`,
})
}
// ---------------------------------------------------------------------------
// Step 4 — setCardStreamingMode
// ---------------------------------------------------------------------------
/**
* 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`,
* 否则卡片会保持"只读"状态,用户点按钮没反应。
*/
export async function setCardStreamingMode(
client: LarkClient,
cardId: string,
streamingMode: boolean,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.card.settings({
data: {
settings: JSON.stringify({ streaming_mode: streamingMode }),
sequence,
},
path: { card_id: cardId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.settings',
context: `seq=${sequence} streaming_mode=${streamingMode}`,
})
}
// ---------------------------------------------------------------------------
// Step 5 — updateCardKitCard
// ---------------------------------------------------------------------------
/**
* 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
* (加 header template、footer、完成样式等)。
*/
export async function updateCardKitCard(
client: LarkClient,
cardId: string,
card: Record<string, unknown>,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.card.update({
data: {
card: { type: 'card_json', data: JSON.stringify(card) },
sequence,
},
path: { card_id: cardId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.update',
context: `seq=${sequence} cardId=${cardId}`,
})
}
@@ -0,0 +1,95 @@
/**
* Feishu inbound message parser.
*
* Converts a raw Feishu `im.message.receive_v1` event payload (the JSON
* string inside `message.content` plus its `message_type`) into a
* structured `InboundPayload` containing:
* - plain text (for direct forwarding to Claude)
* - a list of `PendingDownload` refs describing any attachments we
* need to fetch via FeishuMediaService.downloadResource()
*
* Supports the five message_type values we care about:
* - text → text only
* - post → rich text (text nodes + img + file elements)
* - image → single image_key
* - file → single file_key
* - file_archive → single file_key (same shape as file)
*
* Any other shape returns an empty payload (text: '', downloads: []).
*/
export type PendingDownload =
| { kind: 'image'; fileKey: string; fileName?: string }
| { kind: 'file'; fileKey: string; fileName?: string }
export interface InboundPayload {
text: string
pendingDownloads: PendingDownload[]
}
export function extractInboundPayload(content: string, msgType: string): InboundPayload {
let parsed: any
try {
parsed = JSON.parse(content)
} catch {
return { text: '', pendingDownloads: [] }
}
if (msgType === 'text') {
return {
text: typeof parsed.text === 'string' ? parsed.text : '',
pendingDownloads: [],
}
}
if (msgType === 'image') {
if (typeof parsed.image_key === 'string' && parsed.image_key) {
return {
text: '',
pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }],
}
}
return { text: '', pendingDownloads: [] }
}
if (msgType === 'file' || msgType === 'file_archive') {
if (typeof parsed.file_key === 'string' && parsed.file_key) {
return {
text: '',
pendingDownloads: [
{
kind: 'file',
fileKey: parsed.file_key,
fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined,
},
],
}
}
return { text: '', pendingDownloads: [] }
}
if (msgType === 'post') {
const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[]
const flat = nodes.flat()
const textParts: string[] = []
const downloads: PendingDownload[] = []
for (const node of flat) {
if (!node || typeof node !== 'object') continue
if (node.tag === 'text' || node.tag === 'md') {
const t = node.text ?? node.content ?? ''
if (typeof t === 'string') textParts.push(t)
} else if (node.tag === 'img' && typeof node.image_key === 'string') {
downloads.push({ kind: 'image', fileKey: node.image_key })
} else if (node.tag === 'file' && typeof node.file_key === 'string') {
downloads.push({
kind: 'file',
fileKey: node.file_key,
fileName: typeof node.file_name === 'string' ? node.file_name : undefined,
})
}
}
return { text: textParts.join(''), pendingDownloads: downloads }
}
return { text: '', pendingDownloads: [] }
}
@@ -0,0 +1,149 @@
/**
* 节流 + mutex + 冲突重刷的通用 flush 调度器
*
* 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。
* 实际 flush 工作由构造函数注入的 doFlush 回调负责。
*
* 语义:
* - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流
* - flush() 被 mutex 保护,相同时刻只有一个在跑
* - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷
* - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动
* - complete() 后拒绝所有新 flush
*
* 参考实现: openclaw-lark/src/card/flush-controller.ts
*/
// ---------------------------------------------------------------------------
// Throttle constants
// ---------------------------------------------------------------------------
export const THROTTLE = {
/** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
CARDKIT_MS: 100,
/** im.message.patch 最小间隔 —— 严格速率限制(230020) */
PATCH_MS: 1500,
/** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
LONG_GAP_THRESHOLD_MS: 2000,
/** 长间隔后第一帧的额外延迟,让文本积累更完整 */
BATCH_AFTER_GAP_MS: 300,
} as const
// ---------------------------------------------------------------------------
// FlushController
// ---------------------------------------------------------------------------
export class FlushController {
private flushInProgress = false
private flushResolvers: Array<() => void> = []
private needsReflush = false
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
private lastUpdateTime = 0
private isCompleted = false
private _cardMessageReady = false
constructor(private readonly doFlush: () => Promise<void>) {}
/** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
complete(): void {
this.isCompleted = true
}
/** 取消任何挂起的延迟 flush 计时器。 */
cancelPendingFlush(): void {
if (this.pendingFlushTimer) {
clearTimeout(this.pendingFlushTimer)
this.pendingFlushTimer = null
}
}
/** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
waitForFlush(): Promise<void> {
if (!this.flushInProgress) return Promise.resolve()
return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
}
/**
* 标记卡片消息是否已发送成功,决定 flush 是否被放行。
*
* 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate
* 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。
*/
setCardMessageReady(ready: boolean): void {
this._cardMessageReady = ready
if (ready) this.lastUpdateTime = Date.now()
}
cardMessageReady(): boolean {
return this._cardMessageReady
}
/**
* 执行一次 flush(mutex 保护 + 冲突重刷)。
*
* 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。
*/
async flush(): Promise<void> {
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
return
}
this.flushInProgress = true
this.needsReflush = false
// 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
this.lastUpdateTime = Date.now()
try {
await this.doFlush()
this.lastUpdateTime = Date.now()
} finally {
this.flushInProgress = false
const resolvers = this.flushResolvers
this.flushResolvers = []
for (const resolve of resolvers) resolve()
// 如果 API 调用期间有新事件进来,立即补一次 flush
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
this.needsReflush = false
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, 0)
}
}
}
/**
* 节流更新入口。
*
* @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS,
* Patch 降级路径传 THROTTLE.PATCH_MS。
*/
async throttledUpdate(throttleMs: number): Promise<void> {
if (!this.cardMessageReady()) return
const now = Date.now()
const elapsed = now - this.lastUpdateTime
if (elapsed >= throttleMs) {
this.cancelPendingFlush()
if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
// 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
// 让文本积累更完整,避免只显示一两个字
this.lastUpdateTime = now
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, THROTTLE.BATCH_AFTER_GAP_MS)
} else {
await this.flush()
}
} else if (!this.pendingFlushTimer) {
// 在节流窗口内 —— 延迟到窗口结束再刷
const delay = throttleMs - elapsed
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, delay)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
/**
* Feishu 卡片 Markdown 样式优化
*
* 背景:
* - 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常(字面量显示)。
* 必须降级为 H4/H5 才能正常渲染。
* - Schema 2.0 CardKit 支持完整的 markdown 但需要手动加 `<br>` 间距避免标题/表格/
* 代码块贴得太紧。
* - 卡片有表格数量上限(FEISHU_CARD_TABLE_LIMIT=3),超出会触发 230099/11310。
* - 图片必须是飞书上传过的 `img_xxx` key,其它 URL 会触发 CardKit 错误 200570。
*
* 实现参考: openclaw-lark/src/card/markdown-style.ts + card-error.ts
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310(openclaw 2026-03 实测) */
export const FEISHU_CARD_TABLE_LIMIT = 3
// ---------------------------------------------------------------------------
// Public: optimizeMarkdownForFeishu
// ---------------------------------------------------------------------------
/**
* 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。
*
* - 标题降级: 若原文包含 H1~H3,则 H2~H6 → H5,H1 → H4
* - 代码块内容受保护,不会被降级
* - Schema 2.0: 连续标题/表格/代码块前后加 `<br>` 间距
* - 连续 3+ 空行压缩为 2
* - 删除非 `img_*` 的 markdown 图片引用(防止 CardKit 200570)
* - 任何内部错误都 fallback 到原文,不阻塞消息发送
*
* @param text 原始 markdown
* @param cardVersion 卡片 schema 版本。默认 2 (Schema 2.0 CardKit),
* 1 对应老 Schema 1.0 fallback 路径(已不推荐)。
*/
export function optimizeMarkdownForFeishu(text: string, cardVersion = 2): string {
try {
let r = _optimizeMarkdownForFeishu(text, cardVersion)
r = stripInvalidImageKeys(r)
return r
} catch {
return text
}
}
function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string {
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
// 代码块内的 `#` / `|` 不能被标题降级或表格匹配误伤
const MARK = '___CB_'
const codeBlocks: string[] = []
let r = text.replace(/```[\s\S]*?```/g, (m) => {
return `${MARK}${codeBlocks.push(m) - 1}___`
})
// ── 2. 标题降级 ────────────────────────────────────────────────────
// 只有当原文档(不是保护后的 r)包含 H1~H3 时才执行降级
// 顺序: 先 H2~H6 → H5,再 H1 → H4
// 若先 H1→H4,`####` 会被后面的 `#{2,6}` 再次匹配成 H5
const hasH1toH3 = /^#{1,3} /m.test(text)
if (hasH1toH3) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4
}
// ── 3. Schema 2.0 段落间距 ────────────────────────────────────────
if (cardVersion >= 2) {
// 3a. 连续标题之间加 <br>
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
// 3b. 非表格行直接跟表格行 → 先补空行(保证后续规则生效)
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2')
// 3c. 表格前: 在空行之前插入 <br>(即 `\n\n|` → `\n<br>\n\n|`)
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1')
// 3d. 表格后: 在表格块末尾追加 <br>(跳过后接分隔线/标题/加粗/文末)
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => {
const after = r.slice(offset + m.length).replace(/^\n+/, '')
if (!after || /^(---|#{4,5} |\*\*)/.test(after)) return m
return m + '\n<br>\n'
})
// 3e. 表格前是普通文本: 只保留 <br>,去掉多余空行
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3')
// 3f. 表格前是加粗行: <br> 紧贴加粗行,空行保留在后面
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3')
// 3g. 表格后是普通文本: 去掉多余空行
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3')
}
// ── 4. 压缩多余空行(3 个以上连续换行 → 2 个)────────────────────
// 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤
r = r.replace(/\n{3,}/g, '\n\n')
// ── 5. 还原代码块 ─────────────────────────────────────────────────
// Schema 2.0 时前后加 <br>,让代码块与周围段落拉开距离
codeBlocks.forEach((block, i) => {
const replacement = cardVersion >= 2 ? `\n<br>\n${block}\n<br>\n` : block
r = r.replace(`${MARK}${i}___`, replacement)
})
return r
}
// ---------------------------------------------------------------------------
// stripInvalidImageKeys
// ---------------------------------------------------------------------------
/** 匹配完整的 markdown 图片语法: `![alt](value)` */
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
/**
* 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。
* 防止 CardKit 错误 200570(unknown image key)。
*
* HTTP URL 和本地路径也会被删除 —— 上游(ImageResolver)负责把它们转成
* `img_xxx`,此函数是 safety net。
*/
function stripInvalidImageKeys(text: string): string {
if (!text.includes('![')) return text
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
if (value.startsWith('img_')) return fullMatch
return ''
})
}
// ---------------------------------------------------------------------------
// Table limiting: sanitizeTextForCard
// ---------------------------------------------------------------------------
export type MarkdownTableMatch = {
index: number
length: number
raw: string
}
/**
* 扫描正文里会被飞书卡片**实际渲染**的 markdown 表格位置。
*
* 代码块内的示例表格不会被飞书解析成卡片表格元素,因此要先排除。
* 这份结果供 `sanitizeTextForCard` 判断是否需要降级多余的表格。
*/
export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] {
// 先扫描代码块区间
const codeBlockRanges: Array<{ start: number; end: number }> = []
const codeBlockRegex = /```[\s\S]*?```/g
let cbMatch = codeBlockRegex.exec(text)
while (cbMatch != null) {
codeBlockRanges.push({
start: cbMatch.index,
end: cbMatch.index + cbMatch[0].length,
})
cbMatch = codeBlockRegex.exec(text)
}
const isInsideCodeBlock = (idx: number): boolean =>
codeBlockRanges.some((range) => idx >= range.start && idx < range.end)
// 扫描表格(header | sep | body...)
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g
const matches: MarkdownTableMatch[] = []
let tableMatch = tableRegex.exec(text)
while (tableMatch != null) {
if (!isInsideCodeBlock(tableMatch.index)) {
matches.push({
index: tableMatch.index,
length: tableMatch[0].length,
raw: tableMatch[0],
})
}
tableMatch = tableRegex.exec(text)
}
return matches
}
/**
* 对正文里超出 `tableLimit` 张的 markdown 表格降级为代码块,防止触发
* 230099/11310。前 `tableLimit` 张保持原样(卡片正常渲染),其余用
* 反引号包裹(飞书会当成 code block 而不是表格)。
*/
export function sanitizeTextForCard(
text: string,
tableLimit: number = FEISHU_CARD_TABLE_LIMIT,
): string {
const matches = findMarkdownTablesOutsideCodeBlocks(text)
if (matches.length <= tableLimit) return text
return wrapTablesBeyondLimit(text, matches, Math.max(tableLimit, 0))
}
function wrapTablesBeyondLimit(
text: string,
matches: readonly MarkdownTableMatch[],
keepCount: number,
): string {
if (matches.length <= keepCount) return text
// 从后往前替换,避免前面的替换导致后面的 offset 错乱
let result = text
for (let i = matches.length - 1; i >= keepCount; i--) {
const { index, length, raw } = matches[i]!
const replacement = '```\n' + raw + '\n```'
result = result.slice(0, index) + replacement + result.slice(index + length)
}
return result
}
@@ -0,0 +1,176 @@
/**
* Feishu media service — wraps im.messageResource / im.image / im.file
* so adapters/feishu/index.ts stays focused on flow control.
*
* References:
* - Feishu OpenAPI: POST /open-apis/im/v1/images
* POST /open-apis/im/v1/files
* GET /open-apis/im/v1/messages/{message_id}/resources/{file_key}
* - OpenClaw impl: openclaw-lark/src/messaging/outbound/media.ts:226,281,323,423,454
*/
import * as Lark from '@larksuiteoapi/node-sdk'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
type LarkClient = InstanceType<typeof Lark.Client>
/** Map a filename extension to Feishu's file_type enum. */
function detectFeishuFileType(
fileName: string,
): 'opus' | 'mp4' | 'pdf' | 'doc' | 'xls' | 'ppt' | 'stream' {
const ext = path.extname(fileName).toLowerCase().replace(/^\./, '')
switch (ext) {
case 'opus':
return 'opus'
case 'mp4':
return 'mp4'
case 'pdf':
return 'pdf'
case 'doc':
case 'docx':
return 'doc'
case 'xls':
case 'xlsx':
return 'xls'
case 'ppt':
case 'pptx':
return 'ppt'
default:
return 'stream'
}
}
function guessMime(fileName: string, kind: 'image' | 'file'): string {
const ext = path.extname(fileName).toLowerCase().replace(/^\./, '')
if (kind === 'image') {
return (
({
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
heic: 'image/heic',
} as Record<string, string>)[ext] || 'image/png'
)
}
return (
({
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain',
json: 'application/json',
} as Record<string, string>)[ext] || 'application/octet-stream'
)
}
export interface DownloadParams {
messageId: string
fileKey: string
kind: 'image' | 'file'
fileName?: string
sessionId: string
}
export class FeishuMediaService {
constructor(
private readonly client: LarkClient,
private readonly store: AttachmentStore,
) {}
/** Download an image or file the user sent in Feishu into the local stage. */
async downloadResource(params: DownloadParams): Promise<LocalAttachment> {
const { messageId, fileKey, kind, sessionId } = params
const fallbackName = `${fileKey}${kind === 'image' ? '.png' : ''}`
const name = params.fileName || fallbackName
const target = this.store.resolvePath('feishu', sessionId, name)
// node-sdk returns an object with a `.writeFile(target)` helper
// that dumps the underlying stream. See OpenClaw media.ts:147 and :237.
const resp: any = await (this.client.im as any).messageResource.get({
path: { message_id: messageId, file_key: fileKey },
params: { type: kind },
})
if (typeof resp?.writeFile === 'function') {
await resp.writeFile(target)
} else if (resp?.data instanceof Buffer) {
await this.store.write(target, resp.data)
} else if (resp instanceof Buffer) {
await this.store.write(target, resp)
} else {
throw new Error('[FeishuMedia] Unknown downloadResource response shape')
}
const buffer = await fs.readFile(target)
return {
kind,
name,
path: target,
size: buffer.length,
mimeType: guessMime(name, kind),
buffer,
}
}
/** Upload an image buffer, returns image_key.
* The Lark node-sdk type for `image` accepts `Buffer | ReadStream`,
* so passing the buffer directly is the simplest path. */
async uploadImage(buffer: Buffer, _mime: string): Promise<string> {
const resp: any = await this.client.im.image.create({
data: {
image_type: 'message',
image: buffer,
},
})
const key = resp?.data?.image_key
if (!key) throw new Error('[FeishuMedia] uploadImage: missing image_key')
return key
}
/** Upload a non-image file, returns file_key. */
async uploadFile(buffer: Buffer, fileName: string): Promise<string> {
const resp: any = await this.client.im.file.create({
data: {
file_type: detectFeishuFileType(fileName),
file_name: fileName,
file: buffer,
},
})
const key = resp?.data?.file_key
if (!key) throw new Error('[FeishuMedia] uploadFile: missing file_key')
return key
}
/** Send an image message to a chat. See OpenClaw media.ts:435. */
async sendImageMessage(chatId: string, imageKey: string): Promise<void> {
await this.client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'image',
content: JSON.stringify({ image_key: imageKey }),
},
})
}
/** Send a file message to a chat. See OpenClaw media.ts:466. */
async sendFileMessage(chatId: string, fileKey: string): Promise<void> {
await this.client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'file',
content: JSON.stringify({ file_key: fileKey }),
},
})
}
}
@@ -0,0 +1,612 @@
/**
* 单个 chat 的流式卡片生命周期状态机
*
* 负责把 LLM 的流式文本增量渲染成一张随着内容生长的飞书 CardKit 卡片。
* 封装了:
* - CardKit API 的 5 步调用(create → send → stream × N → settings → update)
* - 节流 + 并发保护(FlushController)
* - Markdown 预处理(optimizeMarkdownForFeishu + sanitizeTextForCard)
* - 错误降级:CardKit 挂了自动切到 im.message.patch + Schema 2.0 卡
* - 速率限制:230020 跳帧,下次重试;230099 表格超限禁用 CardKit 流式
*
* 每个 chatId 一个实例,由 index.ts 的 handleServerMessage 协调 lifecycle。
*/
import type * as Lark from '@larksuiteoapi/node-sdk'
import { FlushController, THROTTLE } from './flush-controller.js'
import {
createCardEntity,
sendCardAsMessage,
streamCardContent,
setCardStreamingMode,
updateCardKitCard,
STREAMING_ELEMENT_ID,
} from './cardkit.js'
import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js'
import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js'
// ---------------------------------------------------------------------------
// Card JSON builders
// ---------------------------------------------------------------------------
/** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。
*
* 只包含一个 markdown 元素 `streaming_content`,初始内容为 loading 提示。
* 由 renderedText() 统一控制显示状态(思考中 / reasoning / 正文),
* 避免静态 loading 元素和 streaming 内容同时显示造成"两个思考中"。
*
* finalize 时整卡 update 替换为纯答复正文。 */
export function buildInitialStreamingCard(): Record<string, unknown> {
return {
schema: '2.0',
config: {
streaming_mode: true,
update_multi: true,
},
body: {
elements: [
{
tag: 'markdown',
content: '☁️ *正在思考中...*',
text_align: 'left',
element_id: STREAMING_ELEMENT_ID,
},
],
},
}
}
/** 已渲染完成的卡片:Schema 2.0,无 streaming_mode,单 markdown 元素。
*
* 代码块的 "10 行代码 >" 手机端 bug 是通过 `optimizeMarkdownForFeishu` 把
* fenced code 降级成纯文字来规避的,不依赖多元素结构。这里就是最朴素的
* 一张纯 markdown 卡片。 */
export function buildRenderedCard(renderedMarkdown: string): Record<string, unknown> {
return {
schema: '2.0',
config: {
update_multi: true,
},
body: {
elements: [
{
tag: 'markdown',
content: renderedMarkdown || ' ',
text_align: 'left',
},
],
},
}
}
/** 错误卡片:红色 header + 错误文本。用于 abort() 兜底。 */
export function buildErrorCard(message: string): Record<string, unknown> {
return {
schema: '2.0',
config: { update_multi: true },
header: {
title: { tag: 'plain_text', content: '❌ 出错了' },
template: 'red',
},
body: {
elements: [
{
tag: 'markdown',
content: message || '未知错误',
},
],
},
}
}
/** 从末尾截取最多 maxLen 个字符;超过时前缀 "..." 保留最新 maxLen-3 个字。
*
* 思考内容往往是"先分析 → 得出结论"的线性过程,截取末尾比截取开头更有用 —— 用户
* 最关心的是"模型现在在想什么",不是"五千个 token 前在想什么"。 */
function truncateReasoningPreview(text: string, maxLen: number): string {
if (text.length <= maxLen) return text
return '...' + text.slice(text.length - maxLen + 3)
}
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
export type StreamingCardPhase =
| 'idle' // constructor 后、ensureCreated 之前
| 'creating' // ensureCreated 进行中
| 'streaming' // 初始卡已发出,接受 appendText
| 'finalizing' // finalize 进行中
| 'completed' // finalize 完成
| 'aborted' // abort 已调用
export type StreamingCardDeps = {
larkClient: Lark.Client
chatId: string
replyToMessageId?: string
}
/** One entry in the tool-use trace displayed above the answer text. */
type ToolStep = {
/** Prefer toolUseId for dedup; fall back to a synthetic id when missing. */
id: string
name: string
status: 'running' | 'done'
}
/** 最多保留的 reasoning 预览字符数,超过则取末尾 + 省略号前缀。 */
const REASONING_PREVIEW_CHARS = 600
/** 连续 streamCardContent 失败多少次后才放弃 CardKit 流式。
* 设成 3 而不是 1,是为了避免单次抖动(网络、临时校验失败等)把整张卡片
* 冻结到 finalize —— 用户看到的就是 "long wait → 一次性 dump"。 */
const STREAM_FAIL_DISABLE_THRESHOLD = 3
export class StreamingCard {
// ---- lifecycle state ----
private phase: StreamingCardPhase = 'idle'
// ---- CardKit state ----
/** CardKit card_id。null = CardKit 创建失败,已退到 patch fallback 模式。 */
private cardId: string | null = null
/** IM message_id。始终应该有值(否则连 patch 也做不了)。 */
private messageId: string | null = null
/** CardKit cardElement.content() 单调递增序列号。 */
private sequence = 0
/** CardKit 流式还在工作。230099 或连续 N 次未知错误之后置为 false,
* 中间帧将跳过,最终 finalize 仍会尝试 settings+update(cardId 仍然有效)。 */
private cardKitStreamActive = false
/** 连续 streamCardContent 未知错误计数。一次成功就清零。 */
private consecutiveStreamFailures = 0
// ---- text state ----
private accumulatedText = ''
private lastFlushedText = ''
/** 累积 thinking_delta,渲染为卡片顶部的推理预览 blockquote。 */
private accumulatedReasoningText = ''
/** 工具调用轨迹:按 startTool 调用顺序排列,completeTool 改其 status。 */
private toolSteps: ToolStep[] = []
// ---- flush ----
private flushController: FlushController
constructor(private readonly deps: StreamingCardDeps) {
this.flushController = new FlushController(() => this.performFlush())
}
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* 首次创建卡片(CardKit 主路径;失败则降级到直发 Schema 2.0 卡 + patch)。
* 幂等:已创建/正在创建时直接返回。
*/
async ensureCreated(): Promise<void> {
if (this.phase !== 'idle') return
this.phase = 'creating'
try {
// CardKit 主路径
const cardId = await createCardEntity(
this.deps.larkClient,
buildInitialStreamingCard(),
)
const messageId = await sendCardAsMessage(
this.deps.larkClient,
this.deps.chatId,
cardId,
this.deps.replyToMessageId,
)
this.cardId = cardId
this.messageId = messageId
this.cardKitStreamActive = true
this.sequence = 1
this.phase = 'streaming'
this.flushController.setCardMessageReady(true)
} catch (cardKitErr) {
// CardKit 不可用(权限、网络、API 兼容性等)→ 降级到直发卡片 + patch
console.warn(
'[Feishu StreamingCard] CardKit create/send failed, falling back to im.message.patch:',
cardKitErr instanceof Error ? cardKitErr.message : cardKitErr,
)
try {
const fallbackResp = await this.deps.larkClient.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: this.deps.chatId,
msg_type: 'interactive',
content: JSON.stringify(buildRenderedCard(' ')),
},
})
const mid = fallbackResp.data?.message_id
if (!mid) {
throw new Error('fallback im.message.create returned no message_id')
}
this.cardId = null
this.messageId = mid
this.cardKitStreamActive = false
this.phase = 'streaming'
this.flushController.setCardMessageReady(true)
} catch (fallbackErr) {
// 兜底都失败了 —— 无法显示任何东西
console.error(
'[Feishu StreamingCard] Fallback card creation also failed:',
fallbackErr instanceof Error ? fallbackErr.message : fallbackErr,
)
this.phase = 'aborted'
throw fallbackErr
}
}
// 卡片可写之后若已有 buffered 内容(text / reasoning / tools),
// 立刻触发一次 flush —— 否则 content_start{tool_use} 或 thinking 在
// ensureCreated 期间到达的状态会一直卡在节流 gate 上,用户看不到。
if (this.hasAnyContent()) {
void this.flushController.throttledUpdate(this.currentThrottle())
}
}
/** 追加文本增量。不等待,只安排一次节流 flush。 */
appendText(delta: string): void {
if (!delta) return
if (this.phase === 'completed' || this.phase === 'aborted') return
this.accumulatedText += delta
void this.flushController.throttledUpdate(this.currentThrottle())
}
/** 追加 reasoning/thinking delta —— 与 appendText 并列,渲染为顶部预览。 */
appendReasoning(delta: string): void {
if (!delta) return
if (this.phase === 'completed' || this.phase === 'aborted') return
this.accumulatedReasoningText += delta
void this.flushController.throttledUpdate(this.currentThrottle())
}
/** 记录一次 tool_use 开始。dedupe 按 toolUseId(缺省时按 name+index)。 */
startTool(toolUseId: string | undefined, toolName: string | undefined): void {
if (this.phase === 'completed' || this.phase === 'aborted') return
if (!toolName) return
const id = toolUseId || `${toolName}#${this.toolSteps.length}`
if (this.toolSteps.some((s) => s.id === id)) return
this.toolSteps.push({ id, name: toolName, status: 'running' })
void this.flushController.throttledUpdate(this.currentThrottle())
}
/** 把指定 tool 的状态从 running 切到 done。先按 id 匹配,再 fallback name。 */
completeTool(toolUseId: string | undefined, toolName: string | undefined): void {
if (this.phase === 'completed' || this.phase === 'aborted') return
let step: ToolStep | undefined
if (toolUseId) {
step = this.toolSteps.find((s) => s.id === toolUseId)
}
if (!step && toolName) {
for (let i = this.toolSteps.length - 1; i >= 0; i--) {
const s = this.toolSteps[i]!
if (s.name === toolName && s.status === 'running') {
step = s
break
}
}
}
if (!step) return
if (step.status === 'done') return
step.status = 'done'
void this.flushController.throttledUpdate(this.currentThrottle())
}
/** 是否已有任何可渲染内容(文本 / 推理 / 工具)。 */
private hasAnyContent(): boolean {
return (
this.accumulatedText.length > 0 ||
this.accumulatedReasoningText.length > 0 ||
this.toolSteps.length > 0
)
}
/**
* 流式结束,切到最终态。
* - 先 waitForFlush 确保中间帧写入完成
* - 然后 close streaming_mode(仅 CardKit 路径)
* - 最后用完整 rendered 卡片 update
* - complete FlushController 锁死,后续 appendText 被忽略
*/
async finalize(): Promise<void> {
if (this.phase === 'completed' || this.phase === 'aborted') return
if (this.phase === 'idle') {
// 完全没开始 —— 直接标记完成
this.phase = 'completed'
this.flushController.complete()
return
}
this.phase = 'finalizing'
this.flushController.cancelPendingFlush()
await this.flushController.waitForFlush()
const finalText = this.terminalText()
try {
if (this.cardId) {
// CardKit 路径: settings(false) + card.update(即使中间 stream 曾失败)
this.sequence += 1
await setCardStreamingMode(
this.deps.larkClient,
this.cardId,
false,
this.sequence,
)
this.sequence += 1
await updateCardKitCard(
this.deps.larkClient,
this.cardId,
buildRenderedCard(finalText),
this.sequence,
)
} else if (this.messageId) {
// Patch fallback 路径: 全量替换
await this.deps.larkClient.im.message.patch({
path: { message_id: this.messageId },
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
})
}
} catch (err) {
console.error(
'[Feishu StreamingCard] finalize failed:',
err instanceof Error ? err.message : err,
)
// 不抛出 —— 用户已经看到某种版本的内容,finalize 失败不是致命错误
} finally {
this.phase = 'completed'
this.lastFlushedText = finalText
this.flushController.complete()
}
}
/** 错误中止 —— 尝试把错误信息渲染到卡片上。 */
async abort(err: Error): Promise<void> {
if (this.phase === 'completed' || this.phase === 'aborted') return
const wasIdle = this.phase === 'idle'
this.phase = 'aborted'
this.flushController.cancelPendingFlush()
await this.flushController.waitForFlush().catch(() => {})
if (wasIdle || !this.messageId) {
// 卡片还没创建成功,没法渲染错误 —— 由上层 sendText 兜底
this.flushController.complete()
return
}
const errCard = buildErrorCard(
`${err.message}${this.accumulatedText ? '\n\n——\n\n' + this.accumulatedText : ''}`,
)
try {
if (this.cardId) {
this.sequence += 1
await setCardStreamingMode(
this.deps.larkClient,
this.cardId,
false,
this.sequence,
).catch(() => {}) // 关流失败无所谓,update 才是关键
this.sequence += 1
await updateCardKitCard(
this.deps.larkClient,
this.cardId,
errCard,
this.sequence,
)
} else {
await this.deps.larkClient.im.message.patch({
path: { message_id: this.messageId },
data: { content: JSON.stringify(errCard) },
})
}
} catch (renderErr) {
console.error(
'[Feishu StreamingCard] abort render failed:',
renderErr instanceof Error ? renderErr.message : renderErr,
)
} finally {
this.flushController.complete()
}
}
// ------------------------------------------------------------------
// Internals
// ------------------------------------------------------------------
/** 当前应使用的节流时长。 */
private currentThrottle(): number {
return this.cardKitStreamActive ? THROTTLE.CARDKIT_MS : THROTTLE.PATCH_MS
}
/** 组合 reasoning + toolSteps + answerText,经 sanitize + optimize 管道出来。
*
* 顺序为 tools → reasoning → answer,以分隔符隔开:
* - tools 永远在最顶部,方便用户先看到 "现在在跑什么"
* - reasoning 居中(thinking 文本)
* - answer 在底部
*
* 整张卡片只用最朴素的 markdown:plain text + emoji + bold + line break。
* **不使用** blockquote / list / heading —— 这些会被
* optimizeMarkdownForFeishu 触发额外的 <br> 注入和 H 降级,并且历史上
* 曾导致飞书 CardKit 校验报错("long wait → 一次性 dump" 退化的根因)。
* 任意 section 为空则忽略;全部为空时返回等待提示。 */
private renderedText(): string {
const sections: string[] = []
if (this.toolSteps.length > 0) {
// 单行 inline 形式: ⚙️ Bash · ✅ Read · ⚙️ Glob ...
// 用中点分隔,比 markdown list 更不容易触发 Feishu 排版异常
const inline = this.toolSteps
.map((s) => `${s.status === 'done' ? '✅' : '⚙️'} ${s.name}`)
.join(' · ')
sections.push(`🛠️ ${inline}`)
}
if (this.accumulatedReasoningText) {
const preview = truncateReasoningPreview(
this.accumulatedReasoningText,
REASONING_PREVIEW_CHARS,
)
// openclaw 风格: 一行 header + 空行 + 原文。不引用 / 不缩进,让飞书
// markdown 元素按普通段落渲染。
sections.push(`💭 **思考中**\n\n${preview}`)
}
if (this.accumulatedText) {
sections.push(this.accumulatedText)
}
if (sections.length === 0) return '☁️ *正在思考中...*'
// 用一行分隔符把 sections 分开,比单纯空行更稳定
const composed = sections.join('\n\n---\n\n')
// 表格数限制在 optimize 之前做 —— sanitize 对原始 markdown 最准
const limited = sanitizeTextForCard(composed)
return optimizeMarkdownForFeishu(limited, 2)
}
/** 终态文本: 只渲染最终答复正文,丢弃 reasoning 和 toolSteps。
*
* 推理过程和工具调用是"过程态"信息,已经在流式中展示给用户看过;
* message_complete 之后用户应该看到一张干净的答复卡(与 Desktop UI 对齐)。
* 这个方法专供 finalize 调用,不要在中间帧用。
*
* 边界情况: 如果完全没有 accumulatedText(比如纯 thinking 没产出答案
* 这种异常 case),退回到 renderedText() 至少把推理留下来当兜底。 */
private terminalText(): string {
if (this.accumulatedText) {
const limited = sanitizeTextForCard(this.accumulatedText)
return optimizeMarkdownForFeishu(limited, 2)
}
return this.renderedText()
}
/** FlushController 调用的 doFlush。 */
private async performFlush(): Promise<void> {
if (this.phase !== 'streaming') return
if (!this.messageId) return
// CardKit 中间帧被禁用但 cardId 仍有效 —— 跳过中间 flush,
// 等 finalize 用 cardId 做最终 settings + update
if (this.cardId && !this.cardKitStreamActive) return
const finalText = this.renderedText()
if (finalText === this.lastFlushedText) return
if (this.cardKitStreamActive && this.cardId) {
// CardKit 主路径
this.sequence += 1
try {
await streamCardContent(
this.deps.larkClient,
this.cardId,
STREAMING_ELEMENT_ID,
finalText,
this.sequence,
)
this.lastFlushedText = finalText
this.consecutiveStreamFailures = 0
} catch (err) {
if (isCardRateLimitError(err)) {
// 跳帧 —— 下次 throttledUpdate 会重试
return
}
if (isCardTableLimitError(err)) {
// 表格超限 —— 禁用流式中间帧,等 finalize 用 update 一次性发完整卡
console.warn(
'[Feishu StreamingCard] 230099 table limit, disabling CardKit streaming',
)
this.cardKitStreamActive = false
return
}
// 其他错误 —— 跳帧重试,避免单次失败把整张卡冻在最初状态。
// 只有连续失败超过阈值才认定 CardKit 不可用并降级 —— 否则
// 用户会看到 "long wait → 完事后一次性把所有内容刷出来" 的体验
// 退化(这是 streamCardContent 一旦报错就 disable 流式造成的)。
this.consecutiveStreamFailures += 1
const errMsg = err instanceof Error ? err.message : String(err)
if (this.consecutiveStreamFailures === 1) {
// 首帧失败先记录一次,避免日志风暴
console.warn(
'[Feishu StreamingCard] stream flush failed (will retry):',
errMsg,
)
}
if (this.consecutiveStreamFailures >= STREAM_FAIL_DISABLE_THRESHOLD) {
console.error(
`[Feishu StreamingCard] stream flush failed ${this.consecutiveStreamFailures}× consecutively, disabling CardKit streaming until finalize:`,
errMsg,
)
this.cardKitStreamActive = false
}
return
}
} else {
// Patch fallback 路径(CardKit 从未成功)
try {
await this.deps.larkClient.im.message.patch({
path: { message_id: this.messageId },
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
})
this.lastFlushedText = finalText
} catch (err) {
if (isCardRateLimitError(err)) return
console.error(
'[Feishu StreamingCard] patch flush failed:',
err instanceof Error ? err.message : err,
)
}
}
}
// ------------------------------------------------------------------
// Test helpers (exposed for unit tests, not part of public API)
// ------------------------------------------------------------------
/** @internal */
_getPhase(): StreamingCardPhase {
return this.phase
}
/** @internal */
_getCardId(): string | null {
return this.cardId
}
/** @internal */
_getMessageId(): string | null {
return this.messageId
}
/** @internal */
_getSequence(): number {
return this.sequence
}
/** @internal */
_isCardKitStreamActive(): boolean {
return this.cardKitStreamActive
}
/** @internal */
_getAccumulatedText(): string {
return this.accumulatedText
}
/** @internal */
_getAccumulatedReasoning(): string {
return this.accumulatedReasoningText
}
/** @internal */
_getToolSteps(): ReadonlyArray<ToolStep> {
return this.toolSteps
}
/** @internal */
_getFlushController(): FlushController {
return this.flushController
}
}
@@ -0,0 +1,23 @@
{
"name": "claude-code-im-adapters",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"telegram": "bun run telegram/index.ts",
"feishu": "bun run feishu/index.ts",
"test": "bun test",
"test:common": "bun test common/",
"test:telegram": "bun test telegram/",
"test:feishu": "bun test feishu/"
},
"dependencies": {
"grammy": "^1.42.0",
"@larksuiteoapi/node-sdk": "^1.60.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.0",
"bun-types": "latest"
}
}
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { TelegramMediaService } from '../media.js'
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
let tmpRoot: string
let originalFetch: typeof fetch
function makeMockBot() {
const fetchMock = mock(async (url: string | URL) => {
const u = typeof url === 'string' ? url : url.toString()
expect(u).toContain('/file/botFAKE_TOKEN/photos/abc.jpg')
return new Response(Buffer.from('PHOTODATA'), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})
})
;(globalThis as any).fetch = fetchMock
return {
token: 'FAKE_TOKEN',
api: {
getFile: mock(async (fileId: string) => ({
file_id: fileId,
file_unique_id: 'unique',
file_path: 'photos/abc.jpg',
})),
sendPhoto: mock(async () => ({ message_id: 1 })),
sendDocument: mock(async () => ({ message_id: 2 })),
},
fetchMock,
}
}
beforeEach(async () => {
originalFetch = globalThis.fetch
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'tg-media-test-'))
})
afterEach(async () => {
globalThis.fetch = originalFetch
await fs.rm(tmpRoot, { recursive: true, force: true })
})
describe('TelegramMediaService', () => {
it('downloadFile fetches the real URL and stores a LocalAttachment', async () => {
const bot = makeMockBot()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new TelegramMediaService(bot as any, store)
const local = await svc.downloadFile('fid_123', 'sess-1', {
fileName: 'abc.jpg',
mimeType: 'image/jpeg',
})
expect(local.kind).toBe('image')
expect(local.name).toBe('abc.jpg')
expect(local.size).toBe('PHOTODATA'.length)
expect(local.buffer.toString()).toBe('PHOTODATA')
const onDisk = await fs.readFile(local.path)
expect(onDisk.toString()).toBe('PHOTODATA')
})
it('sendPhoto calls bot.api.sendPhoto with InputFile-like payload', async () => {
const bot = makeMockBot()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new TelegramMediaService(bot as any, store)
await svc.sendPhoto(42, Buffer.from('IMG'), 'caption text')
expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1)
const args = (bot.api.sendPhoto as any).mock.calls[0]
expect(args[0]).toBe(42)
// grammY InputFile wraps the buffer; just verify it's an object.
expect(args[1]).toBeDefined()
expect(args[2]?.caption).toBe('caption text')
})
it('sendDocument calls bot.api.sendDocument', async () => {
const bot = makeMockBot()
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
const svc = new TelegramMediaService(bot as any, store)
await svc.sendDocument(42, Buffer.from('DOC'), 'spec.pdf')
expect(bot.api.sendDocument).toHaveBeenCalledTimes(1)
const args = (bot.api.sendDocument as any).mock.calls[0]
expect(args[0]).toBe(42)
expect(args[1]).toBeDefined()
})
})
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'bun:test'
import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js'
/**
* Telegram Adapter 翻译逻辑测试
*
* 由于 grammy Bot 需要实际 Token 才能初始化,
* 这里测试的是不依赖 Bot 实例的核心翻译逻辑。
*/
describe('Telegram message formatting', () => {
describe('long message splitting', () => {
it('splits messages at Telegram 4096 char limit', () => {
const longText = 'a'.repeat(8000)
const chunks = splitMessage(longText, 4000)
expect(chunks.length).toBe(2)
expect(chunks[0]!.length).toBeLessThanOrEqual(4000)
expect(chunks[1]!.length).toBeLessThanOrEqual(4000)
})
it('keeps short messages as single chunk', () => {
const chunks = splitMessage('Hello World', 4000)
expect(chunks).toEqual(['Hello World'])
})
it('splits at paragraph boundary when possible', () => {
const text = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000)
const chunks = splitMessage(text, 3000)
expect(chunks.length).toBe(2)
})
})
describe('permission request formatting', () => {
it('formats Bash command request', () => {
const result = formatPermissionRequest('Bash', { command: 'npm test' }, 'abcde')
expect(result).toContain('🔐')
expect(result).toContain('Bash')
expect(result).toContain('npm test')
expect(result).toContain('abcde')
})
it('formats Write file request', () => {
const result = formatPermissionRequest(
'Write',
{ file_path: '/src/index.ts', content: 'console.log("hello")' },
'fghij',
)
expect(result).toContain('Write')
expect(result).toContain('index.ts')
expect(result).toContain('fghij')
})
it('truncates long input in permission request', () => {
const longInput = { command: 'x'.repeat(500) }
const result = formatPermissionRequest('Bash', longInput, 'xxxxx')
expect(result.length).toBeLessThan(600)
})
})
describe('callback_data parsing', () => {
it('parses permit:requestId:yes format', () => {
const data = 'permit:abcde:yes'
const parts = data.split(':')
expect(parts[0]).toBe('permit')
expect(parts[1]).toBe('abcde')
expect(parts[2]).toBe('yes')
})
it('parses permit:requestId:no format', () => {
const data = 'permit:abcde:no'
const parts = data.split(':')
expect(parts[2]).toBe('no')
})
it('ignores non-permit callbacks', () => {
const data = 'other:action'
expect(data.startsWith('permit:')).toBe(false)
})
})
describe('MarkdownV2 escaping', () => {
it('escapes underscores', () => {
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
})
it('escapes multiple special chars', () => {
const result = escapeMarkdownV2('file.ts (line 42)')
expect(result).toBe('file\\.ts \\(line 42\\)')
})
it('handles code blocks safely', () => {
const result = escapeMarkdownV2('`code`')
expect(result).toBe('\\`code\\`')
})
})
describe('whitelist logic', () => {
it('empty allowedUsers means allow all', () => {
const allowedUsers: number[] = []
const isAllowed = (userId: number) =>
allowedUsers.length === 0 || allowedUsers.includes(userId)
expect(isAllowed(12345)).toBe(true)
expect(isAllowed(99999)).toBe(true)
})
it('non-empty allowedUsers filters correctly', () => {
const allowedUsers = [111, 222]
const isAllowed = (userId: number) =>
allowedUsers.length === 0 || allowedUsers.includes(userId)
expect(isAllowed(111)).toBe(true)
expect(isAllowed(222)).toBe(true)
expect(isAllowed(333)).toBe(false)
})
})
})
@@ -0,0 +1,754 @@
/**
* Telegram Adapter for Claude Code Desktop
*
* 基于 grammY 的轻量 Telegram Bot,直连服务端 /ws/:sessionId。
* 启动:TELEGRAM_BOT_TOKEN=xxx bun run telegram/index.ts
*/
import { Bot, InlineKeyboard, type Context } from 'grammy'
import * as path from 'node:path'
import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
import { MessageBuffer } from '../common/message-buffer.js'
import { MessageDedup } from '../common/message-dedup.js'
import { enqueue } from '../common/chat-queue.js'
import { loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
formatPermissionRequest,
splitMessage,
} from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { TelegramMediaService } from './media.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import type { AttachmentRef } from '../common/ws-bridge.js'
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
import type { PendingUpload } from '../common/attachment/attachment-types.js'
import * as fs from 'node:fs/promises'
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
// ---------- init ----------
const config = loadConfig()
if (!config.telegram.botToken) {
console.error('[Telegram] Missing TELEGRAM_BOT_TOKEN. Set env or ~/.claude/adapters.json')
process.exit(1)
}
const bot = new Bot(config.telegram.botToken)
const bridge = new WsBridge(config.serverUrl, 'tg')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const attachmentStore = new AttachmentStore()
const media = new TelegramMediaService(bot, attachmentStore)
attachmentStore.gc().catch((err) => {
console.warn('[Telegram] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
})
// Track placeholder messages for streaming updates
const placeholders = new Map<string, { chatId: string; messageId: number }>()
// Track accumulated text per chat for streaming
const accumulatedText = new Map<string, string>()
// Message buffers per chat
const buffers = new Map<string, MessageBuffer>()
// Track chats waiting for project selection
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
/** Per-chat outbound image watcher for Agent-produced markdown images. */
const tgImageWatchers = new Map<string, ImageBlockWatcher>()
function getTgWatcher(chatId: string): ImageBlockWatcher {
let w = tgImageWatchers.get(chatId)
if (!w) {
w = new ImageBlockWatcher()
tgImageWatchers.set(chatId, w)
}
return w
}
type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
model?: string
pendingPermissionCount: number
}
// ---------- helpers ----------
function getBuffer(chatId: string): MessageBuffer {
let buf = buffers.get(chatId)
if (!buf) {
buf = new MessageBuffer(async (text, isComplete) => {
await flushToTelegram(chatId, text, isComplete)
})
buffers.set(chatId, buf)
}
return buf
}
function getRuntimeState(chatId: string): ChatRuntimeState {
let state = runtimeStates.get(chatId)
if (!state) {
state = { state: 'idle', pendingPermissionCount: 0 }
runtimeStates.set(chatId, state)
}
return state
}
function clearTransientChatState(chatId: string): void {
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
const runtime = getRuntimeState(chatId)
runtime.state = 'idle'
runtime.verb = undefined
runtime.pendingPermissionCount = 0
tgImageWatchers.delete(chatId)
}
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
const stored = sessionStore.get(chatId)
if (!stored) return null
if (!bridge.hasSession(chatId)) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
const opened = await bridge.waitForOpen(chatId)
if (!opened) return null
}
return stored
}
async function buildStatusText(chatId: string): Promise<string> {
const stored = await ensureExistingSession(chatId)
if (!stored) return formatImStatus(null)
const runtime = getRuntimeState(chatId)
let projectName = path.basename(stored.workDir) || stored.workDir
let branch: string | null = null
try {
const gitInfo = await httpClient.getGitInfo(stored.sessionId)
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
branch = gitInfo.branch
} catch {
// Ignore git lookup failures and fall back to stored workDir
}
let taskCounts:
| {
total: number
pending: number
inProgress: number
completed: number
}
| undefined
try {
const tasks = await httpClient.getTasksForSession(stored.sessionId)
if (tasks.length > 0) {
taskCounts = {
total: tasks.length,
pending: tasks.filter((task) => task.status === 'pending').length,
inProgress: tasks.filter((task) => task.status === 'in_progress').length,
completed: tasks.filter((task) => task.status === 'completed').length,
}
}
} catch {
// Ignore task lookup failures in IM status summary
}
return formatImStatus({
sessionId: stored.sessionId,
projectName,
branch,
model: runtime.model,
state: runtime.state,
verb: runtime.verb,
pendingPermissionCount: runtime.pendingPermissionCount,
taskCounts,
})
}
async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise<void> {
const numericChatId = Number(chatId)
const prev = accumulatedText.get(chatId) ?? ''
const fullText = prev + newText
accumulatedText.set(chatId, fullText)
const placeholder = placeholders.get(chatId)
if (placeholder) {
if (isComplete) {
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
try {
await bot.api.editMessageText(numericChatId, placeholder.messageId, chunks[0]!)
} catch { /* ignore */ }
for (let i = 1; i < chunks.length; i++) {
await bot.api.sendMessage(numericChatId, chunks[i]!)
}
} else {
const displayText = fullText.slice(0, TELEGRAM_TEXT_LIMIT - 2) + ' ▍'
try {
await bot.api.editMessageText(numericChatId, placeholder.messageId, displayText)
} catch { /* ignore */ }
}
} else if (isComplete && fullText.trim()) {
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
for (const chunk of chunks) {
await bot.api.sendMessage(numericChatId, chunk)
}
}
if (isComplete) {
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
}
}
// ---------- session management ----------
async function ensureSession(chatId: string): Promise<boolean> {
if (bridge.hasSession(chatId)) return true
const stored = sessionStore.get(chatId)
if (stored) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return await bridge.waitForOpen(chatId)
}
const workDir = config.defaultProjectDir
if (workDir) {
return await createSessionForChat(chatId, workDir)
}
await showProjectPicker(chatId)
return false
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
const numericChatId = Number(chatId)
try {
// Always tear down any stale WS connection before creating a new session.
// Without this, bridge.connectSession() below would short-circuit when an
// old OPEN connection still exists, leaving messages routed to the old session.
bridge.resetSession(chatId)
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
const opened = await bridge.waitForOpen(chatId)
if (!opened) {
await bot.api.sendMessage(numericChatId, '⚠️ 连接服务器超时,请重试。')
return false
}
return true
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
const numericChatId = Number(chatId)
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await bot.api.sendMessage(numericChatId,
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。')
return
}
const lines = projects.slice(0, 10).map((p, i) =>
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
)
pendingProjectSelection.set(chatId, true)
await bot.api.sendMessage(numericChatId,
`选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ---------- outbound media dispatch ----------
/** Upload a PendingUpload found in streaming output and send it via
* bot.api.sendPhoto as an independent message. Runs fire-and-forget
* from the stream handler so streaming text isn't blocked. */
async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
const numericChatId = Number(chatId)
try {
let buffer: Buffer
let mime = 'image/png'
switch (pending.source.kind) {
case 'base64': {
buffer = Buffer.from(pending.source.data, 'base64')
mime = pending.source.mime
break
}
case 'path': {
buffer = await fs.readFile(pending.source.path)
mime = pending.source.mime ?? 'image/png'
break
}
case 'url': {
const resp = await fetch(pending.source.url)
if (!resp.ok) {
throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
}
buffer = Buffer.from(await resp.arrayBuffer())
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
break
}
}
const check = checkAttachmentLimit('image', buffer.length, mime)
if (!check.ok) {
console.warn('[Telegram] Outbound image rejected:', check.hint)
return
}
await media.sendPhoto(numericChatId, buffer, pending.alt)
} catch (err) {
console.error(
'[Telegram] dispatchOutboundMedia failed:',
err instanceof Error ? err.message : err,
)
}
}
// ---------- server message handler ----------
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const numericChatId = Number(chatId)
const buf = getBuffer(chatId)
const runtime = getRuntimeState(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
runtime.state = msg.state
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
if (msg.state === 'thinking' && !placeholders.has(chatId)) {
const sent = await bot.api.sendMessage(numericChatId, '💭 思考中...')
placeholders.set(chatId, { chatId, messageId: sent.message_id })
accumulatedText.set(chatId, '')
}
break
case 'content_start':
if (msg.blockType === 'text') {
if (!placeholders.has(chatId)) {
const sent = await bot.api.sendMessage(numericChatId, '▍')
placeholders.set(chatId, { chatId, messageId: sent.message_id })
accumulatedText.set(chatId, '')
}
} else if (msg.blockType === 'tool_use') {
// Finalize current text placeholder before tool calls,
// so text after tools gets a fresh message
await buf.complete()
// If placeholder still exists (buffer was already empty), clean up directly
if (placeholders.has(chatId)) {
const text = accumulatedText.get(chatId)
if (text?.trim()) {
try {
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text)
} catch { /* ignore */ }
}
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
}
}
break
case 'content_delta':
if (msg.text) {
buf.append(msg.text)
const newUploads = getTgWatcher(chatId).feed(msg.text)
for (const pending of newUploads) {
void dispatchOutboundMedia(chatId, pending)
}
}
break
case 'thinking':
if (placeholders.has(chatId)) {
try {
await bot.api.editMessageText(
numericChatId,
placeholders.get(chatId)!.messageId,
`💭 ${msg.text.slice(0, 200)}...`,
)
} catch { /* ignore */ }
}
break
case 'tool_use_complete':
// Tool details are noise for IM users; visible in Desktop if needed.
break
case 'tool_result':
// Tool errors are handled internally by the AI (retries etc.)
// No need to notify the user for every failed attempt.
break
case 'permission_request': {
runtime.pendingPermissionCount += 1
runtime.state = 'permission_pending'
const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
const keyboard = new InlineKeyboard()
.text('✅ 允许', `permit:${msg.requestId}:yes`)
.text('❌ 拒绝', `permit:${msg.requestId}:no`)
await bot.api.sendMessage(numericChatId, text, { reply_markup: keyboard })
break
}
case 'message_complete':
runtime.state = 'idle'
runtime.verb = undefined
await buf.complete()
// Ensure placeholder is always cleaned up even if buffer was already empty
if (placeholders.has(chatId)) {
const text = accumulatedText.get(chatId)
if (text?.trim()) {
try {
const chunks = splitMessage(text, TELEGRAM_TEXT_LIMIT)
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, chunks[0]!)
for (let i = 1; i < chunks.length; i++) {
await bot.api.sendMessage(numericChatId, chunks[i]!)
}
} catch { /* ignore */ }
}
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
}
break
case 'error':
runtime.state = 'idle'
runtime.verb = undefined
// Auto-recover from stale thinking block signatures by creating a fresh session.
// This happens when the API key or provider changed since the session was created.
if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
const stored = sessionStore.get(chatId)
const workDir = stored?.workDir || config.defaultProjectDir
if (workDir) {
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...')
clearTransientChatState(chatId)
bridge.resetSession(chatId)
sessionStore.delete(chatId)
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await bot.api.sendMessage(numericChatId, '✅ 已重建会话,请重新发送消息。')
} else {
await bot.api.sendMessage(numericChatId, '❌ 重建会话失败,请发送 /new 手动新建。')
}
} else {
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
}
} else {
await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
}
break
case 'system_notification':
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
const model = (msg.data as Record<string, unknown>).model
if (typeof model === 'string' && model.trim()) {
runtime.model = model
}
}
break
}
}
// ---------- bot handlers ----------
async function sendHelp(ctx: Context): Promise<void> {
await ctx.reply(`👋 Claude Code Bot 已就绪。\n\n${formatImHelp()}`)
}
bot.command('start', (ctx) => void sendHelp(ctx))
bot.command('help', (ctx) => void sendHelp(ctx))
/** Reset session state and start a new session for chatId.
* If `query` is provided, match a project by index or name;
* otherwise use defaultProjectDir or show the picker. */
async function startNewSession(chatId: string, query?: string): Promise<void> {
const numericChatId = Number(chatId)
bridge.resetSession(chatId)
sessionStore.delete(chatId)
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
runtimeStates.delete(chatId)
tgImageWatchers.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) {
await bot.api.sendMessage(numericChatId,
`✅ 已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} — ${p.realPath}`).join('\n')
await bot.api.sendMessage(numericChatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await bot.api.sendMessage(numericChatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ ${err instanceof Error ? err.message : String(err)}`)
}
} else {
const workDir = config.defaultProjectDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await bot.api.sendMessage(numericChatId, '✅ 已新建会话,可以开始对话了。')
}
} else {
await showProjectPicker(chatId)
}
}
}
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
await startNewSession(chatId, ctx.match?.trim() || undefined)
})
bot.command('projects', async (ctx) => {
const chatId = String(ctx.chat.id)
await showProjectPicker(chatId)
})
bot.command('stop', (ctx) => {
const chatId = String(ctx.chat.id)
void (async () => {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await ctx.reply(formatImStatus(null))
return
}
bridge.sendStopGeneration(chatId)
await ctx.reply('⏹ 已发送停止信号。')
})()
})
bot.command('status', async (ctx) => {
const chatId = String(ctx.chat.id)
await ctx.reply(await buildStatusText(chatId))
})
bot.command('clear', (ctx) => {
const chatId = String(ctx.chat.id)
void (async () => {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await ctx.reply(formatImStatus(null))
return
}
clearTransientChatState(chatId)
const sent = bridge.sendUserMessage(chatId, '/clear')
if (!sent) {
await ctx.reply('⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
return
}
await ctx.reply('🧹 已清空当前会话上下文。')
})()
})
/** Shared per-user-message pipeline: dedup, pairing check, project-pick
* routing, enqueue, ensureSession, sendUserMessage with attachments.
* Caller has already extracted text and attachments from the context. */
async function routeUserMessage(
ctx: Context,
text: string,
attachments: AttachmentRef[],
): Promise<void> {
if (!ctx.from || ctx.chat?.type !== 'private') return
if (!dedup.tryRecord(String(ctx.message?.message_id))) return
const chatId = String(ctx.chat.id)
const userId = ctx.from.id
if (!isAllowedUser('telegram', userId)) {
const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
if (success) {
await ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
} else {
await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
}
return
}
enqueue(chatId, async () => {
if (pendingProjectSelection.has(chatId)) {
if (text.trim()) await startNewSession(chatId, text.trim())
return
}
const ready = await ensureSession(chatId)
if (!ready) return
const effective =
text || (attachments.length > 0 ? '(用户发送了附件)' : '')
if (!effective && attachments.length === 0) return
const sent = bridge.sendUserMessage(chatId, effective, attachments.length ? attachments : undefined)
if (!sent) {
await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
}
})
}
/** Scan ctx.message for photo/document/video/audio/voice, download
* each via TelegramMediaService, apply size/mime limits, and produce
* a ready-to-send AttachmentRef[] plus any rejection hints. */
async function collectAttachmentsFromCtx(
ctx: Context,
): Promise<{ attachments: AttachmentRef[]; rejections: string[] }> {
const msg = ctx.message
if (!msg || !ctx.chat) return { attachments: [], rejections: [] }
const sessionId = sessionStore.get(String(ctx.chat.id))?.sessionId ?? String(ctx.chat.id)
const attachments: AttachmentRef[] = []
const rejections: string[] = []
const runOne = async (
fileId: string,
fileName?: string,
mimeType?: string,
): Promise<void> => {
try {
const local = await media.downloadFile(fileId, sessionId, { fileName, mimeType })
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
if (!check.ok) {
rejections.push(check.hint)
return
}
if (local.kind === 'image') {
attachments.push({
type: 'image',
name: local.name,
data: local.buffer.toString('base64'),
mimeType: local.mimeType,
})
} else {
attachments.push({
type: 'file',
name: local.name,
path: local.path,
mimeType: local.mimeType,
})
}
} catch (err) {
console.error('[Telegram] downloadFile failed:', err)
rejections.push('📎 附件下载失败,请稍后重试')
}
}
// Photos: grammY exposes an array of sizes, largest last.
if (msg.photo && msg.photo.length > 0) {
const largest = msg.photo[msg.photo.length - 1]!
await runOne(largest.file_id, `photo-${largest.file_unique_id}.jpg`, 'image/jpeg')
}
if (msg.document) {
await runOne(msg.document.file_id, msg.document.file_name, msg.document.mime_type)
}
if (msg.video) {
await runOne(msg.video.file_id, msg.video.file_name, msg.video.mime_type)
}
if (msg.audio) {
await runOne(msg.audio.file_id, msg.audio.file_name, msg.audio.mime_type)
}
if (msg.voice) {
await runOne(
msg.voice.file_id,
`voice-${msg.voice.file_unique_id}.ogg`,
msg.voice.mime_type ?? 'audio/ogg',
)
}
return { attachments, rejections }
}
bot.on('message:text', async (ctx) => {
await routeUserMessage(ctx, ctx.message.text, [])
})
bot.on(
['message:photo', 'message:document', 'message:video', 'message:audio', 'message:voice'],
async (ctx) => {
const caption = ctx.message.caption ?? ''
const { attachments, rejections } = await collectAttachmentsFromCtx(ctx)
for (const r of rejections) {
await ctx.reply(r).catch(() => {})
}
if (attachments.length === 0 && !caption.trim()) return
await routeUserMessage(ctx, caption, attachments)
},
)
bot.on('callback_query:data', async (ctx) => {
const data = ctx.callbackQuery.data
if (!data.startsWith('permit:')) return
const parts = data.split(':')
if (parts.length !== 3) return
const requestId = parts[1]!
const allowed = parts[2] === 'yes'
const chatId = String(ctx.callbackQuery.message?.chat.id)
bridge.sendPermissionResponse(chatId, requestId, allowed)
const runtime = getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
try {
await ctx.editMessageText(
ctx.callbackQuery.message?.text + `\n\n${statusText}`,
)
} catch { /* ignore */ }
await ctx.answerCallbackQuery(statusText)
})
// ---------- start ----------
console.log('[Telegram] Starting bot...')
console.log(`[Telegram] Server: ${config.serverUrl}`)
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
bot.start({
onStart: () => console.log('[Telegram] Bot is running!'),
})
// Graceful shutdown
process.on('SIGINT', () => {
console.log('[Telegram] Shutting down...')
bot.stop()
bridge.destroy()
dedup.destroy()
process.exit(0)
})
@@ -0,0 +1,89 @@
/**
* Telegram media service — wraps grammY download/upload helpers.
*
* Telegram file download flow:
* 1. bot.api.getFile(file_id) → { file_path }
* 2. GET https://api.telegram.org/file/bot<token>/<file_path>
*/
import { InputFile, type Bot } from 'grammy'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
function extOf(fileName?: string): string {
if (!fileName) return ''
const m = /\.([^./\\]+)$/.exec(fileName)
return m ? m[1]!.toLowerCase() : ''
}
function classifyKind(mime: string | undefined, fileName: string): 'image' | 'file' {
if (mime?.startsWith('image/')) return 'image'
const ext = extOf(fileName)
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'].includes(ext)) return 'image'
return 'file'
}
export interface DownloadHint {
fileName?: string
mimeType?: string
}
export class TelegramMediaService {
constructor(
private readonly bot: Bot,
private readonly store: AttachmentStore,
) {}
async downloadFile(
fileId: string,
sessionId: string,
hint: DownloadHint = {},
): Promise<LocalAttachment> {
const file = await this.bot.api.getFile(fileId)
if (!file.file_path) {
throw new Error(`[TelegramMedia] getFile returned no file_path for ${fileId}`)
}
const token = (this.bot as unknown as { token: string }).token
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`
const resp = await fetch(url)
if (!resp.ok) {
throw new Error(`[TelegramMedia] fetch failed: ${resp.status} ${resp.statusText}`)
}
const buffer = Buffer.from(await resp.arrayBuffer())
const mime = hint.mimeType ?? resp.headers.get('content-type') ?? undefined
const fallbackName = file.file_path.split('/').pop() || fileId
const name = hint.fileName ?? fallbackName
const kind = classifyKind(mime, name)
const target = this.store.resolvePath('telegram', sessionId, name)
await this.store.write(target, buffer)
return {
kind,
name,
path: target,
size: buffer.length,
mimeType: mime ?? (kind === 'image' ? 'image/png' : 'application/octet-stream'),
buffer,
}
}
async sendPhoto(chatId: number, buffer: Buffer, caption?: string): Promise<void> {
await this.bot.api.sendPhoto(
chatId,
new InputFile(buffer),
caption ? { caption } : undefined,
)
}
async sendDocument(
chatId: number,
buffer: Buffer,
fileName: string,
caption?: string,
): Promise<void> {
await this.bot.api.sendDocument(
chatId,
new InputFile(buffer, fileName),
caption ? { caption } : undefined,
)
}
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["bun-types"],
"outDir": "dist",
"rootDir": ".",
"paths": {
"@server/*": ["../src/server/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Backwards-compatible shim — forwards to the new HeiCode entrypoint.
# Will be removed in a future release.
exec "$(dirname "${BASH_SOURCE[0]}")/heicode" "$@"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Get your current working directory and export it as an environment variable.
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
cd "$ROOT_DIR"
# When spawned by the desktop/web server as a child CLI process,
# skip .env loading — the server has already set the correct env
# via heicode/settings.json. Loading .env would re-inject stale
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
# override the active provider config.
if [[ "${HEICODE_SKIP_DOTENV:-${CC_HAHA_SKIP_DOTENV:-0}}" == "1" ]]; then
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
ENV_FILE_FLAG="--env-file=/dev/null"
elif [[ -f .env ]]; then
ENV_FILE_FLAG="--env-file=.env"
else
ENV_FILE_FLAG=""
fi
# Force recovery CLI (simple readline REPL, no Ink TUI)
if [[ "${HEICODE_FORCE_RECOVERY_CLI:-${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}}" == "1" ]]; then
exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
fi
# Default: full CLI with Ink TUI
exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
preload = ["./preload.ts"]
@@ -0,0 +1,30 @@
# Claude Code Haha Desktop
基于 Tauri 2 + React 的桌面客户端。
## 开发
```bash
bun install
bun run tauri dev
```
## 构建
```bash
# macOS (Apple Silicon)
./scripts/build-macos-arm64.sh
# Windows (x64, MSI only)
.\scripts\build-windows-x64.ps1
```
构建产物位于 `build-artifacts/` 目录,文件名会显式包含平台、架构和包类型。
## 常见问题
### macOS 提示"已损坏,无法打开"
```bash
xattr -cr /Applications/Claude\ Code\ Haha.app
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
# Override parent bunfig to avoid preload interference
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Code Companion</title>
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
<style>
/* Prevent white flash on load */
html { background: #FAF9F5; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,57 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
"build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",
"mermaid": "^11.14.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0",
"react-dom": "^18.3.1",
"react-shiki": "^0.9.2",
"shiki": "^4.0.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
"vite": "^6.0.7",
"vitest": "^3.0.4"
},
"optionalDependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.2.1"
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,17 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Douyin</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 728 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,311 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${DESKTOP_DIR}/.." && pwd)"
TARGET_TRIPLE="aarch64-apple-darwin"
TAURI_TARGET_DIR="${DESKTOP_DIR}/src-tauri/target"
CANONICAL_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/macos-arm64"
APP_BUNDLE_NAME="Claude Code Haha.app"
APP_BUNDLE_ID="com.claude-code-haha.desktop"
usage() {
cat <<'EOF'
Build Claude Code Haha desktop for macOS Apple Silicon and output a DMG.
Usage:
./desktop/scripts/build-macos-arm64.sh [extra tauri build args...]
Environment:
SKIP_INSTALL=1 Skip `bun install` in the repo root and desktop app.
SIGN_BUILD=1 Remove the default `--no-sign` flag and allow signed builds.
OPEN_OUTPUT=1 Open the canonical artifact output directory in Finder after a successful build.
Examples:
./desktop/scripts/build-macos-arm64.sh
SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh
SIGN_BUILD=1 ./desktop/scripts/build-macos-arm64.sh --skip-stapling
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "[build-macos-arm64] This script must run on macOS." >&2
exit 1
fi
if [[ "$(uname -m)" != "arm64" ]]; then
echo "[build-macos-arm64] This script is intended for Apple Silicon hosts (arm64)." >&2
exit 1
fi
for command in bun cargo rustc codesign hdiutil; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "[build-macos-arm64] Missing required command: ${command}" >&2
exit 1
fi
done
if [[ "${SKIP_INSTALL:-0}" != "1" ]]; then
echo "[build-macos-arm64] Installing root dependencies..."
(cd "${REPO_ROOT}" && bun install)
echo "[build-macos-arm64] Installing desktop dependencies..."
(cd "${DESKTOP_DIR}" && bun install)
fi
# ── 清理 + 显式预热前端 / sidecar ────────────────────────────
# 之前遇到过"改了 src/ 代码,build 的 .app 里 sidecar 还是旧的"的诡异
# case —— 根因是 Bun.build / Tauri bundler 某一层的缓存把旧 sidecar
# binary 复用进了新 .app(.app 被删干净了也救不回来,因为 binary 源在
# desktop/src-tauri/binaries/)。
#
# 这里做三件事强制 fresh build:
# 1) 硬删 sidecar 源 binary + Tauri bundle 目录 + 前端 dist
# 2) 显式跑 bun run build + bun run build:sidecars
# 3) tauri build 用 --config 覆盖 beforeBuildCommand 为 true(no-op),
# 避免 sidecar 被重复编译浪费 ~10s
# 任一步失败,整个脚本立即退出(set -e)。
echo "[build-macos-arm64] Cleaning stale sidecar binaries and bundle output..."
rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/bundle"
rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle"
rm -rf "${DESKTOP_DIR}/dist"
rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo"
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/build/claude-code-desktop-"*
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/.fingerprint/claude-code-desktop-"*
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/claude_code_desktop-"*
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/libclaude_code_desktop-"*
echo "[build-macos-arm64] Rebuilding frontend (tsc + vite)..."
(cd "${DESKTOP_DIR}" && bun run build)
echo "[build-macos-arm64] Rebuilding sidecar for ${TARGET_TRIPLE}..."
(cd "${DESKTOP_DIR}" && TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}" bun run build:sidecars)
TAURI_ARGS=(
bunx
tauri
build
--target
"${TARGET_TRIPLE}"
--bundles
app,dmg
--ci
--config
'{"build":{"beforeBuildCommand":"true"}}'
)
if [[ "${SIGN_BUILD:-0}" != "1" ]]; then
TAURI_ARGS+=(--no-sign)
fi
if [[ "$#" -gt 0 ]]; then
TAURI_ARGS+=("$@")
fi
echo "[build-macos-arm64] Building DMG for ${TARGET_TRIPLE}..."
(
cd "${DESKTOP_DIR}"
export TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}"
"${TAURI_ARGS[@]}"
)
TARGETED_DMG_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/dmg"
FALLBACK_DMG_DIR="${TAURI_TARGET_DIR}/release/bundle/dmg"
TARGETED_APP_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/macos"
FALLBACK_APP_DIR="${TAURI_TARGET_DIR}/release/bundle/macos"
LEGACY_BUNDLE_ROOT="${TAURI_TARGET_DIR}/release/bundle"
mkdir -p "${CANONICAL_OUTPUT_DIR}"
find "${CANONICAL_OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
find_latest_file() {
local search_dir="$1"
local pattern="$2"
if [[ -d "${search_dir}" ]]; then
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" | sort | tail -n 1
fi
}
find_latest_dir() {
local search_dir="$1"
local pattern="$2"
if [[ -d "${search_dir}" ]]; then
find "${search_dir}" -maxdepth 1 -type d -name "${pattern}" | sort | tail -n 1
fi
}
LATEST_DMG="$(find_latest_file "${TARGETED_DMG_DIR}" '*.dmg')"
if [[ -z "${LATEST_DMG}" ]]; then
LATEST_DMG="$(find_latest_file "${FALLBACK_DMG_DIR}" '*.dmg')"
fi
LATEST_APP="$(find_latest_dir "${TARGETED_APP_DIR}" '*.app')"
if [[ -z "${LATEST_APP}" ]]; then
LATEST_APP="$(find_latest_dir "${FALLBACK_APP_DIR}" '*.app')"
fi
build_canonical_dmg() {
local app_bundle="$1"
local dmg_output="$2"
local staging_dir
local rw_dmg
staging_dir="$(mktemp -d "${TMPDIR:-/tmp}/cc-haha-dmg.XXXXXX")"
rw_dmg="$(mktemp "${TMPDIR:-/tmp}/cc-haha-rw.XXXXXX").dmg"
cp -R "${app_bundle}" "${staging_dir}/"
ln -s /Applications "${staging_dir}/Applications"
# Create a read-write DMG first so we can customize the Finder layout
hdiutil create \
-volname "Claude Code Haha" \
-srcfolder "${staging_dir}" \
-ov \
-format UDRW \
"${rw_dmg}" >/dev/null
rm -rf "${staging_dir}"
# Mount the read-write DMG and apply Finder layout via AppleScript
local dev_name mount_dir
dev_name=$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse "${rw_dmg}" \
| grep -E '^/dev/' | head -1 | awk '{print $1}')
mount_dir=$(hdiutil info | grep -E "${dev_name}" | tail -1 | awk '{$1=$2=""; print}' | xargs)
# Finder AppleScript 在新版 macOS (Sequoia+) 上某些属性
# (toolbar/statusbar visible、某些 container window 属性) 不再支持,
# 会返回 -10006 错误。美化失败不是 blocker —— 即便 layout 没设上,
# DMG 本身还是可用的,只是用户打开时看到的是 Finder 默认排布。
# 所以这里允许 osascript 非零退出,只 warn,不让 set -e 炸掉整个脚本。
if ! osascript <<APPLESCRIPT
tell application "Finder"
tell disk "Claude Code Haha"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {100, 100, 760, 500}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set position of item "${APP_BUNDLE_NAME}" of container window to {180, 170}
set position of item "Applications" of container window to {480, 170}
close
open
update without registering applications
delay 2
close
end tell
end tell
APPLESCRIPT
then
echo "[build-macos-arm64] WARN: Finder layout AppleScript failed (likely macOS version incompatible); DMG will use default Finder layout" >&2
fi
sync
# osascript 可能已经让 Finder 打开了 volume 窗口,正常 detach 可能因为
# "Resource busy" 失败。失败时用 -force 二次尝试。
hdiutil detach "${dev_name}" -quiet 2>/dev/null \
|| hdiutil detach "${dev_name}" -force -quiet
# Convert to compressed read-only DMG
hdiutil convert "${rw_dmg}" -format UDZO -o "${dmg_output}" -ov >/dev/null
rm -f "${rw_dmg}"
}
codesign_cdhash() {
local executable="$1"
codesign -d --verbose=4 "${executable}" 2>&1 \
| awk -F= '/^CDHash=/{print $2; exit}'
}
sign_canonical_app_bundle() {
local app_bundle="$1"
local sidecar="${app_bundle}/Contents/MacOS/claude-sidecar"
local sidecar_cdhash_before=""
local sidecar_cdhash_after=""
if [[ -x "${sidecar}" ]]; then
sidecar_cdhash_before="$(codesign_cdhash "${sidecar}")"
fi
# Tauri --no-sign leaves the outer .app with no sealed resources, which
# fails strict bundle validation once Resources/icon.icns exists. Sign only
# the outer bundle: do not pass --deep, because re-signing claude-sidecar
# changes its code-signature hash and breaks existing macOS Keychain ACLs.
codesign --force --sign - --timestamp=none "${app_bundle}"
if [[ -x "${sidecar}" ]]; then
sidecar_cdhash_after="$(codesign_cdhash "${sidecar}")"
if [[ "${sidecar_cdhash_before}" != "${sidecar_cdhash_after}" ]]; then
echo "[build-macos-arm64] ERROR: sidecar signature hash changed while signing app bundle" >&2
echo "[build-macos-arm64] before=${sidecar_cdhash_before}" >&2
echo "[build-macos-arm64] after=${sidecar_cdhash_after}" >&2
exit 1
fi
fi
codesign --verify --deep --strict --verbose=2 "${app_bundle}"
}
if [[ -n "${LATEST_DMG}" ]]; then
cp -f "${LATEST_DMG}" "${CANONICAL_OUTPUT_DIR}/"
fi
if [[ -n "${LATEST_APP}" ]]; then
# 不要 deep re-sign。曾经脚本在这里跑过
# `codesign --force --deep --sign - --identifier <bundle-id>` 来统一
# sidecar 和外层的 signing identifier,但这会改变 sidecar binary 的
# code signature hash —— macOS Keychain ACL 按 hash 识别 caller,
# 重签完再访问时会被 ACL 当作"陌生 binary"静默拒绝,导致 CLI 读不到
# OAuth token,最终请求打到 Anthropic 返回 403 "Request not allowed"。
# 这里只浅签外层 bundle,让 .app 拥有有效资源封印,同时保留 sidecar hash。
cp -R "${LATEST_APP}" "${CANONICAL_OUTPUT_DIR}/"
sign_canonical_app_bundle "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}"
rm -f "${CANONICAL_OUTPUT_DIR}/"*.dmg
build_canonical_dmg \
"${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" \
"${CANONICAL_OUTPUT_DIR}/$(basename "${LATEST_DMG:-Claude Code Haha_0.1.0_aarch64.dmg}")"
fi
cat > "${CANONICAL_OUTPUT_DIR}/BUILD_INFO.txt" <<EOF
Target triple: ${TARGET_TRIPLE}
Canonical output: ${CANONICAL_OUTPUT_DIR}
Source DMG: ${LATEST_DMG:-not found}
Source app: ${LATEST_APP:-not found}
Built at: $(date '+%Y-%m-%d %H:%M:%S %z')
EOF
if [[ -d "${LEGACY_BUNDLE_ROOT}" ]]; then
rm -rf "${LEGACY_BUNDLE_ROOT}"
fi
echo
echo "[build-macos-arm64] Build finished."
if [[ -n "${LATEST_DMG}" ]]; then
echo "[build-macos-arm64] DMG source: ${LATEST_DMG}"
else
echo "[build-macos-arm64] No DMG found in ${TARGETED_DMG_DIR} or ${FALLBACK_DMG_DIR}" >&2
fi
if [[ -n "${LATEST_APP}" ]]; then
echo "[build-macos-arm64] App source: ${LATEST_APP}"
else
echo "[build-macos-arm64] No .app found in ${TARGETED_APP_DIR} or ${FALLBACK_APP_DIR}" >&2
fi
echo "[build-macos-arm64] Canonical output: ${CANONICAL_OUTPUT_DIR}"
echo "[build-macos-arm64] Removed legacy bundle dir: ${LEGACY_BUNDLE_ROOT}"
if [[ "${OPEN_OUTPUT:-0}" == "1" ]]; then
open "${CANONICAL_OUTPUT_DIR}"
fi
@@ -0,0 +1,186 @@
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
const desktopRoot = path.resolve(import.meta.dir, '..')
const repoRoot = path.resolve(desktopRoot, '..')
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
const targetTriple =
process.env.TAURI_ENV_TARGET_TRIPLE ||
process.env.CARGO_BUILD_TARGET ||
(await detectHostTriple())
const bunTarget = mapTargetTripleToBun(targetTriple)
// 编译前先扫一遍 src/ 把所有缺失的 ant-internal 模块在磁盘上 stub 出来。
// 见 desktop/scripts/scan-missing-imports.ts。
console.log('[build-sidecars] scanning for missing imports...')
const scanProc = Bun.spawn(
['bun', 'run', path.join(desktopRoot, 'scripts/scan-missing-imports.ts')],
{ cwd: repoRoot, stdout: 'inherit', stderr: 'inherit' },
)
const scanExit = await scanProc.exited
if (scanExit !== 0) {
throw new Error(`[build-sidecars] scan-missing-imports failed (exit ${scanExit})`)
}
await mkdir(binariesDir, { recursive: true })
// 单一合并 sidecar:server / cli 共享一份 bun runtime + 共享依赖代码。
// 调用方(Tauri lib.rs / conversationService)通过第一个 positional 参数
// 选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/claude-sidecar.ts'),
outfileBase: path.join(binariesDir, `claude-sidecar-${targetTriple}`),
productName: 'Claude Code Sidecar',
bunTarget,
})
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
async function detectHostTriple() {
const proc = Bun.spawn(['rustc', '-vV'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`[build-sidecars] rustc -vV failed: ${stderr || stdout}`)
}
const hostLine = stdout
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('host: '))
if (!hostLine) {
throw new Error('[build-sidecars] Could not detect Rust host triple')
}
return hostLine.replace('host: ', '')
}
function mapTargetTripleToBun(triple: string) {
switch (triple) {
case 'aarch64-apple-darwin':
return 'bun-darwin-arm64'
case 'x86_64-apple-darwin':
return 'bun-darwin-x64'
case 'x86_64-pc-windows-msvc':
return 'bun-windows-x64'
case 'aarch64-pc-windows-msvc':
return 'bun-windows-arm64'
case 'x86_64-unknown-linux-gnu':
return 'bun-linux-x64-baseline'
case 'aarch64-unknown-linux-gnu':
return 'bun-linux-arm64'
case 'x86_64-unknown-linux-musl':
return 'bun-linux-x64-musl'
case 'aarch64-unknown-linux-musl':
return 'bun-linux-arm64-musl'
default:
throw new Error(`[build-sidecars] Unsupported target triple: ${triple}`)
}
}
async function compileExecutable({
entrypoint,
outfileBase,
productName,
bunTarget,
}: {
entrypoint: string
outfileBase: string
productName: string
bunTarget: string
}) {
const result = await Bun.build({
entrypoints: [entrypoint],
// minify whitespace + identifiers + dead-code 大概能省 5-15% 的二进制大小,
// 代价是 stack trace 里的函数名变成短名 —— 终端用户场景可接受。
minify: { whitespace: true, identifiers: true, syntax: true },
sourcemap: 'none',
target: 'bun',
// 可选 npm 包:开 telemetry / 用 sharp 图像 / 用 Bedrock/Vertex 等
// 替代 provider 时才需要,全部不在顶层 package.json 里。标 external
// 让 bun build 跳过解析;运行时 import 在没装时自然失败,由 try/catch
// 或 feature() gate 兜底。
external: [
// OpenTelemetry exporters(开 OTEL_* env 时才加载)
'@opentelemetry/exporter-trace-otlp-grpc',
'@opentelemetry/exporter-trace-otlp-http',
'@opentelemetry/exporter-trace-otlp-proto',
'@opentelemetry/exporter-logs-otlp-grpc',
'@opentelemetry/exporter-logs-otlp-http',
'@opentelemetry/exporter-logs-otlp-proto',
'@opentelemetry/exporter-metrics-otlp-grpc',
'@opentelemetry/exporter-metrics-otlp-http',
'@opentelemetry/exporter-metrics-otlp-proto',
'@opentelemetry/exporter-prometheus',
// 替代 LLM provider —— 默认不用,用户自装
'@aws-sdk/client-bedrock',
'@aws-sdk/client-sts',
'@anthropic-ai/bedrock-sdk',
'@anthropic-ai/foundry-sdk',
'@anthropic-ai/vertex-sdk',
'@azure/identity',
// ant-internal / 可选工具
'@anthropic-ai/mcpb',
'fflate',
'sharp',
'react-devtools-core',
],
compile: {
target: bunTarget,
outfile: outfileBase,
autoloadTsconfig: true,
autoloadPackageJson: true,
windows: {
title: productName,
publisher: 'Claude Code',
description: productName,
hideConsole: true,
},
},
})
if (!result.success) {
const logs = result.logs.map((log) => log.message).join('\n')
throw new Error(`[build-sidecars] Failed to compile ${productName}:\n${logs}`)
}
const outputPath = result.outputs[0]?.path ?? outfileBase
console.log(`[build-sidecars] ${productName} -> ${outputPath}`)
// macOS Apple System Policy (ASP) requires valid code signatures on all
// executables. Bun-compiled binaries ship with an invalid/empty signature
// that causes "load code signature error 4" and SIGKILL at launch.
// Fix: strip the broken signature, then ad-hoc sign.
if (process.platform === 'darwin') {
await adHocSignMacBinary(outputPath)
}
}
async function adHocSignMacBinary(outputPath: string) {
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
stdout: 'inherit',
stderr: 'inherit',
})
await strip.exited
const sign = Bun.spawn(
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
{ stdout: 'inherit', stderr: 'inherit' },
)
const signExit = await sign.exited
if (signExit !== 0) {
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
}
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
}
@@ -0,0 +1,298 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$TauriArgs
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$desktopDir = (Resolve-Path (Join-Path $scriptDir '..')).Path
$repoRoot = (Resolve-Path (Join-Path $desktopDir '..')).Path
$targetTriple = 'x86_64-pc-windows-msvc'
$tauriTargetDir = Join-Path $desktopDir 'src-tauri\target'
$canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64'
$activeOutputDir = $canonicalOutputDir
$appVersion = (Get-Content -Path (Join-Path $desktopDir 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json).version
function Write-Step {
param([string]$Message)
Write-Host "[build-windows-x64] $Message"
}
function Assert-WindowsHost {
if ($env:OS -ne 'Windows_NT') {
throw '[build-windows-x64] This script must run on Windows.'
}
}
function Assert-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[build-windows-x64] Missing required command: $Name"
}
}
function Import-VsDevEnvironment {
$vswhere = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vswhere)) {
throw '[build-windows-x64] Could not find vswhere.exe. Install Visual Studio 2022 Build Tools with the C++ workload.'
}
$installationPath = & $vswhere `
-products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
-property installationPath |
Select-Object -First 1
if (-not $installationPath) {
throw '[build-windows-x64] Missing Visual C++ build tools. Install the "Desktop development with C++" / VC.Tools.x86.x64 workload first.'
}
$vsDevCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
if (-not (Test-Path $vsDevCmd)) {
throw "[build-windows-x64] Could not find VsDevCmd.bat under $installationPath"
}
Write-Step "Importing MSVC environment from $vsDevCmd"
$env:VSCMD_SKIP_SENDTELEMETRY = '1'
$envDump = & cmd.exe /d /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set"
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] Failed to initialize Visual Studio build environment (exit $LASTEXITCODE)"
}
foreach ($line in $envDump) {
if ($line -match '^(.*?)=(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
}
}
}
function Get-RustCargoBinDir {
return Join-Path $env:USERPROFILE '.cargo\bin'
}
function Ensure-RustInPath {
$cargoBinDir = Get-RustCargoBinDir
if ((Test-Path $cargoBinDir) -and -not (($env:Path -split ';') -contains $cargoBinDir)) {
$env:Path = "$cargoBinDir;$env:Path"
}
}
function Get-LatestArtifact {
param(
[string[]]$SearchRoots,
[string[]]$Patterns
)
foreach ($root in $SearchRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $Patterns) {
$match = Get-ChildItem -Path $root -File -Filter $pattern -ErrorAction SilentlyContinue |
Sort-Object Name |
Select-Object -Last 1
if ($match) {
return $match
}
}
}
return $null
}
function Get-StagedArtifactName {
param([string]$ArtifactName)
switch -Regex ($ArtifactName) {
'^latest\.json$' { return 'latest.json' }
'\.msi\.zip\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip.sig" }
'\.msi\.zip$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip" }
'\.msi\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.sig" }
'\.msi$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi" }
default { return $ArtifactName }
}
}
function Resolve-OutputDirectory {
param([string]$PreferredPath)
New-Item -ItemType Directory -Force -Path $PreferredPath | Out-Null
$existingArtifacts = Get-ChildItem -Path $PreferredPath -Force -ErrorAction SilentlyContinue
foreach ($artifact in $existingArtifacts) {
try {
Remove-Item -LiteralPath $artifact.FullName -Force -Recurse
} catch {
$fallbackPath = "$PreferredPath-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Write-Step "Could not clear locked artifact '$($artifact.FullName)'. Using fallback output directory: $fallbackPath"
New-Item -ItemType Directory -Force -Path $fallbackPath | Out-Null
return $fallbackPath
}
}
return $PreferredPath
}
Assert-WindowsHost
Assert-Command bun
Ensure-RustInPath
Import-VsDevEnvironment
Assert-Command cargo
Assert-Command rustc
Assert-Command bunx
if ($env:SKIP_INSTALL -ne '1') {
Write-Step 'Installing root dependencies...'
Push-Location $repoRoot
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in repo root (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
Write-Step 'Installing desktop dependencies...'
Push-Location $desktopDir
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in desktop (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
$adaptersDir = Join-Path $repoRoot 'adapters'
if (Test-Path (Join-Path $adaptersDir 'package.json')) {
Write-Step 'Installing adapter dependencies...'
Push-Location $adaptersDir
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in adapters (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
}
}
$tauriBuildArgs = @(
'tauri',
'build',
'--target',
$targetTriple,
'--bundles',
'msi',
'--ci'
)
$tempConfigPath = $null
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
$tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json'
$tempConfig = @{
bundle = @{
createUpdaterArtifacts = $false
}
} | ConvertTo-Json -Depth 10
Set-Content -Path $tempConfigPath -Value $tempConfig -Encoding UTF8
Write-Step 'TAURI_SIGNING_PRIVATE_KEY not set, disabling updater artifacts for local build'
$tauriBuildArgs += @('--config', $tempConfigPath)
}
if ($null -ne $TauriArgs) {
$remainingArgs = @($TauriArgs)
if ($remainingArgs.Count -gt 0) {
$tauriBuildArgs += $remainingArgs
}
}
Write-Step "Building Windows desktop app for $targetTriple"
Push-Location $desktopDir
try {
$env:TAURI_ENV_TARGET_TRIPLE = $targetTriple
& bunx @tauriBuildArgs
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] tauri build failed (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
if ($tempConfigPath -and (Test-Path $tempConfigPath)) {
Remove-Item -LiteralPath $tempConfigPath -Force
}
}
$activeOutputDir = Resolve-OutputDirectory -PreferredPath $canonicalOutputDir
$bundleRoots = @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle"),
(Join-Path $tauriTargetDir 'release\bundle')
)
$artifactPatterns = @('*.msi', '*.msi.sig', '*.msi.zip', '*.msi.zip.sig', 'latest.json')
$copiedArtifacts = New-Object System.Collections.Generic.List[string]
foreach ($root in $bundleRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $artifactPatterns) {
$artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
foreach ($artifact in $artifacts) {
$destinationName = Get-StagedArtifactName -ArtifactName $artifact.Name
$destination = Join-Path $activeOutputDir $destinationName
Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force
if (-not $copiedArtifacts.Contains($destination)) {
$copiedArtifacts.Add($destination) | Out-Null
}
}
}
}
$msiInstaller = Get-LatestArtifact -SearchRoots @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"),
(Join-Path $tauriTargetDir 'release\bundle\msi')
) -Patterns @('*.msi')
$msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' }
$buildInfo = @(
"App version: $appVersion"
"Target triple: $targetTriple"
"Canonical output: $canonicalOutputDir"
"Actual output: $activeOutputDir"
"Windows installer (MSI): $msiInstallerPath"
"Artifacts copied: $($copiedArtifacts.Count)"
"Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
)
Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInfo -Encoding UTF8
Write-Host ''
Write-Step 'Build finished.'
Write-Step "Artifacts output: $activeOutputDir"
if ($msiInstaller) {
Write-Step "MSI installer source: $($msiInstaller.FullName)"
} else {
Write-Step 'No MSI installer found under bundle directories.'
}
Write-Step "Canonical output: $canonicalOutputDir"
if ($env:OPEN_OUTPUT -eq '1') {
Invoke-Item $canonicalOutputDir
}
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DESKTOP_DIR="$ROOT_DIR/desktop"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
API_PORT="${PORTS%% *}"
WEB_PORT="${PORTS##* }"
BASE_URL="http://127.0.0.1:${API_PORT}"
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-install-e2e-${RUN_ID}"
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")"
SERVER_LOG="${ARTIFACT_DIR}/server.log"
WEB_LOG="${ARTIFACT_DIR}/web.log"
if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
else
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}"
mkdir -p "${CONFIG_DIR}"
fi
cleanup() {
local exit_code=$?
if [[ -n "${SERVER_PID:-}" ]]; then
kill "${SERVER_PID}" >/dev/null 2>&1 || true
wait "${SERVER_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${WEB_PID:-}" ]]; then
kill "${WEB_PID}" >/dev/null 2>&1 || true
wait "${WEB_PID}" >/dev/null 2>&1 || true
fi
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
if [[ $exit_code -ne 0 ]]; then
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
else
rm -rf "${ARTIFACT_DIR}"
fi
}
trap cleanup EXIT
echo "Starting isolated backend on ${BASE_URL}"
(
cd "${ROOT_DIR}"
CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}"
) >"${SERVER_LOG}" 2>&1 &
SERVER_PID=$!
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
(
cd "${DESKTOP_DIR}"
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
) >"${WEB_LOG}" 2>&1 &
WEB_PID=$!
wait_for_http() {
local url="$1"
for _ in $(seq 1 120); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Timed out waiting for ${url}" >&2
return 1
}
browser_body() {
agent-browser --session "${SESSION_NAME}" get text body
}
wait_for_body_contains() {
local needle="$1"
local attempts="${2:-180}"
for _ in $(seq 1 "${attempts}"); do
if browser_body | grep -Fq "${needle}"; then
return 0
fi
sleep 2
done
echo "Timed out waiting for page text: ${needle}" >&2
return 1
}
wait_for_body_contains_any() {
local attempts="$1"
shift
for _ in $(seq 1 "${attempts}"); do
local body
body="$(browser_body)"
for needle in "$@"; do
if grep -Fq "${needle}" <<<"${body}"; then
return 0
fi
done
sleep 2
done
echo "Timed out waiting for any expected page text: $*" >&2
return 1
}
wait_for_path() {
local path="$1"
local attempts="${2:-180}"
for _ in $(seq 1 "${attempts}"); do
if [[ -e "${path}" ]]; then
return 0
fi
sleep 2
done
echo "Timed out waiting for path: ${path}" >&2
return 1
}
AB="agent-browser --session ${SESSION_NAME}"
TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。"
SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。"
PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram"
SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max"
wait_for_http "${BASE_URL}/health"
wait_for_http "http://127.0.0.1:${WEB_PORT}"
${AB} open "${WEB_URL}"
${AB} click 'button:has-text("Settings")'
${AB} click 'button:has-text("Install")'
${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null
run_install_prompt() {
local prompt="$1"
${AB} click 'button:has-text("New install chat")'
${AB} fill 'textarea' "${prompt}"
${AB} click 'button:has-text("Send request")'
}
echo "Running plugin install flow"
run_install_prompt "${TELEGRAM_PROMPT}"
wait_for_body_contains 'telegram@claude-plugins-official'
wait_for_path "${PLUGIN_DIR}"
${AB} click 'button:has-text("Open Plugins")'
wait_for_body_contains 'Enabled1'
wait_for_body_contains 'telegram Enabled'
${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null
echo "Running skill install flow"
${AB} click 'button:has-text("Install")'
run_install_prompt "${SKILL_PROMPT}"
wait_for_body_contains 'claude-code-templates@latest'
wait_for_path "${SKILL_DIR}"
${AB} click 'button:has-text("Open Skills")'
wait_for_body_contains 'Installed Skills'
wait_for_body_contains 'ui-ux-pro-max'
${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null
echo "Install Center E2E passed"
echo "Config dir: ${CONFIG_DIR}"
echo "API port: ${API_PORT}"
echo "Web port: ${WEB_PORT}"
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DESKTOP_DIR="$ROOT_DIR/desktop"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
API_PORT="${PORTS%% *}"
WEB_PORT="${PORTS##* }"
BASE_URL="http://127.0.0.1:${API_PORT}"
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-rewind-e2e-${RUN_ID}"
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-e2e-${RUN_ID}-XXXX")"
PROJECT_DIR="${ARTIFACT_DIR}/project"
SERVER_LOG="${ARTIFACT_DIR}/server.log"
WEB_LOG="${ARTIFACT_DIR}/web.log"
mkdir -p "${PROJECT_DIR}/src"
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
export const ORIGINAL_VALUE = 'before-rewind'
export function readValue() {
return ORIGINAL_VALUE
}
EOF
cat > "${PROJECT_DIR}/README.md" <<'EOF'
# Rewind E2E Fixture
This project is created automatically by the agent-browser rewind E2E script.
EOF
cat > "${PROJECT_DIR}/package.json" <<'EOF'
{
"name": "rewind-e2e-fixture",
"private": true,
"type": "module"
}
EOF
cleanup() {
local exit_code=$?
if [[ -n "${SERVER_PID:-}" ]]; then
kill "${SERVER_PID}" >/dev/null 2>&1 || true
wait "${SERVER_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${WEB_PID:-}" ]]; then
kill "${WEB_PID}" >/dev/null 2>&1 || true
wait "${WEB_PID}" >/dev/null 2>&1 || true
fi
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
if [[ $exit_code -ne 0 ]]; then
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
else
rm -rf "${ARTIFACT_DIR}"
fi
}
trap cleanup EXIT
echo "Starting backend on ${BASE_URL}"
(
cd "${ROOT_DIR}"
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
) >"${SERVER_LOG}" 2>&1 &
SERVER_PID=$!
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
(
cd "${DESKTOP_DIR}"
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
) >"${WEB_LOG}" 2>&1 &
WEB_PID=$!
wait_for_http() {
local url="$1"
for _ in $(seq 1 120); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Timed out waiting for ${url}" >&2
return 1
}
wait_for_http "${BASE_URL}/health"
wait_for_http "http://127.0.0.1:${WEB_PORT}"
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
-H 'Content-Type: application/json' \
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
UNIQUE_TITLE="E2E Rewind ${RUN_ID}"
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
PROMPT="Edit src/app.js and replace before-rewind with after-rewind. Do not modify any other file. Reply with DONE when finished."
TARGET_FILE="${PROJECT_DIR}/src/app.js"
AB="agent-browser --session ${SESSION_NAME}"
${AB} open "${WEB_URL}"
${AB} wait 2000
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
${AB} wait 2000
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
${AB} find text "${UNIQUE_TITLE}" click
${AB} wait 1000
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
${AB} fill 'textarea' "${PROMPT}"
${AB} find role button click --name "Run"
for _ in $(seq 1 180); do
${AB} find role button click --name "Allow" >/dev/null 2>&1 || true
${AB} find role button click --name "Allow for session" >/dev/null 2>&1 || true
if grep -q "after-rewind" "${TARGET_FILE}"; then
break
fi
sleep 2
done
if ! grep -q "after-rewind" "${TARGET_FILE}"; then
echo "Timed out waiting for edited file contents" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-edit-timeout.png" >/dev/null || true
exit 1
fi
${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null
${AB} eval "const button = [...document.querySelectorAll('button')].find((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (!button) throw new Error('Rewind button not found'); button.click();"
${AB} wait 1500
${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null
${AB} find role button click --name "Rewind here"
for _ in $(seq 1 120); do
if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then
break
fi
sleep 1
done
if ! grep -q "before-rewind" "${TARGET_FILE}" || grep -q "after-rewind" "${TARGET_FILE}"; then
echo "Timed out waiting for rewind to restore original file contents" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-rewind-timeout.png" >/dev/null || true
exit 1
fi
${AB} wait 1000
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
if [[ "${PREFILL_VALUE}" != "${PROMPT}" ]]; then
echo "Composer prefill mismatch after rewind" >&2
echo "Expected: ${PROMPT}" >&2
echo "Actual: ${PREFILL_VALUE}" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
exit 1
fi
MESSAGE_COUNT="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.messages.length);')"
if [[ "${MESSAGE_COUNT}" != "0" ]]; then
echo "Expected rewound session to have 0 transcript messages, got ${MESSAGE_COUNT}" >&2
exit 1
fi
${AB} screenshot "${ARTIFACT_DIR}/05-after-rewind.png" >/dev/null
echo "Rewind E2E passed"
echo "API port: ${API_PORT}"
echo "Web port: ${WEB_PORT}"
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DESKTOP_DIR="$ROOT_DIR/desktop"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
API_PORT="${PORTS%% *}"
WEB_PORT="${PORTS##* }"
BASE_URL="http://127.0.0.1:${API_PORT}"
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-rewind-complex-e2e-${RUN_ID}"
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-complex-e2e-${RUN_ID}-XXXX")"
PROJECT_DIR="${ARTIFACT_DIR}/project"
SERVER_LOG="${ARTIFACT_DIR}/server.log"
WEB_LOG="${ARTIFACT_DIR}/web.log"
mkdir -p "${PROJECT_DIR}/src"
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
export const STEP = 'base'
export function readStep() {
return STEP
}
EOF
cat > "${PROJECT_DIR}/README.md" <<'EOF'
# Complex Rewind E2E Fixture
Initial README content.
EOF
cat > "${PROJECT_DIR}/package.json" <<'EOF'
{
"name": "complex-rewind-e2e-fixture",
"private": true,
"type": "module"
}
EOF
cleanup() {
local exit_code=$?
if [[ -n "${SERVER_PID:-}" ]]; then
kill "${SERVER_PID}" >/dev/null 2>&1 || true
wait "${SERVER_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${WEB_PID:-}" ]]; then
kill "${WEB_PID}" >/dev/null 2>&1 || true
wait "${WEB_PID}" >/dev/null 2>&1 || true
fi
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
if [[ $exit_code -ne 0 ]]; then
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
else
rm -rf "${ARTIFACT_DIR}"
fi
}
trap cleanup EXIT
echo "Starting backend on ${BASE_URL}"
(
cd "${ROOT_DIR}"
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
) >"${SERVER_LOG}" 2>&1 &
SERVER_PID=$!
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
(
cd "${DESKTOP_DIR}"
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
) >"${WEB_LOG}" 2>&1 &
WEB_PID=$!
wait_for_http() {
local url="$1"
for _ in $(seq 1 120); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Timed out waiting for ${url}" >&2
return 1
}
try_agent_browser() {
local pid
(agent-browser --session "${SESSION_NAME}" "$@" >/dev/null 2>&1) &
pid=$!
for _ in $(seq 1 5); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
wait "$pid" >/dev/null 2>&1 || true
return 0
fi
sleep 1
done
kill "$pid" >/dev/null 2>&1 || true
wait "$pid" >/dev/null 2>&1 || true
}
try_allow_buttons() {
try_agent_browser find role button click --name "Allow"
try_agent_browser find role button click --name "Allow for session"
}
wait_for_file_contains() {
local file="$1"
local expected="$2"
local timeout="${3:-180}"
for _ in $(seq 1 "$timeout"); do
if [[ -f "$file" ]] && grep -q "$expected" "$file"; then
return 0
fi
try_allow_buttons
sleep 2
done
echo "Timed out waiting for ${file} to contain ${expected}" >&2
return 1
}
wait_for_http "${BASE_URL}/health"
wait_for_http "http://127.0.0.1:${WEB_PORT}"
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
-H 'Content-Type: application/json' \
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
UNIQUE_TITLE="Complex Rewind ${RUN_ID}"
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
APP_FILE="${PROJECT_DIR}/src/app.js"
README_FILE="${PROJECT_DIR}/README.md"
GENERATED_FILE="${PROJECT_DIR}/src/generated.js"
FIRST_PROMPT="Edit src/app.js and replace STEP = 'base' with STEP = 'turn-one'. Do not modify any other file. Reply with DONE when finished."
SECOND_PROMPT="Make exactly these three changes: 1. In src/app.js replace STEP = 'turn-one' with STEP = 'turn-two'. 2. Append a new line to README.md that says Second turn touched README. 3. Create src/generated.js with exactly: export const GENERATED = 'second-turn'. Reply with DONE when finished."
AB="agent-browser --session ${SESSION_NAME}"
${AB} open "${WEB_URL}"
${AB} wait 2000
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
${AB} wait 2000
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
${AB} find text "${UNIQUE_TITLE}" click
${AB} wait 1000
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
${AB} fill 'textarea' "${FIRST_PROMPT}"
${AB} find role button click --name "Run"
wait_for_file_contains "${APP_FILE}" "turn-one" 180
if grep -q "turn-two" "${APP_FILE}" || grep -q "Second turn touched README" "${README_FILE}" || [[ -e "${GENERATED_FILE}" ]]; then
echo "Unexpected second-turn state after first prompt" >&2
exit 1
fi
${AB} screenshot "${ARTIFACT_DIR}/03-after-first-turn.png" >/dev/null
${AB} fill 'textarea' "${SECOND_PROMPT}"
${AB} find role button click --name "Run"
wait_for_file_contains "${APP_FILE}" "turn-two" 180
wait_for_file_contains "${README_FILE}" "Second turn touched README" 60
wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60
${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null
${AB} eval "const buttons = [...document.querySelectorAll('button')].filter((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (buttons.length < 2) throw new Error('Expected at least two Rewind buttons, found ' + buttons.length); buttons[buttons.length - 1].click();"
${AB} wait 1500
${AB} screenshot "${ARTIFACT_DIR}/05-rewind-second-turn-modal.png" >/dev/null
${AB} find role button click --name "Rewind here"
for _ in $(seq 1 120); do
if grep -q "turn-one" "${APP_FILE}" \
&& ! grep -q "turn-two" "${APP_FILE}" \
&& ! grep -q "Second turn touched README" "${README_FILE}" \
&& [[ ! -e "${GENERATED_FILE}" ]]; then
break
fi
sleep 1
done
if ! grep -q "turn-one" "${APP_FILE}" || grep -q "turn-two" "${APP_FILE}"; then
echo "src/app.js was not restored to first-turn state" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-app-restore.png" >/dev/null || true
exit 1
fi
if grep -q "Second turn touched README" "${README_FILE}"; then
echo "README.md still contains second-turn edit" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-readme-restore.png" >/dev/null || true
exit 1
fi
if [[ -e "${GENERATED_FILE}" ]]; then
echo "Generated file still exists after rewinding second turn" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-generated-file.png" >/dev/null || true
exit 1
fi
${AB} wait 1000
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
if [[ "${PREFILL_VALUE}" != "${SECOND_PROMPT}" ]]; then
echo "Composer prefill mismatch after second-turn rewind" >&2
echo "Expected: ${SECOND_PROMPT}" >&2
echo "Actual: ${PREFILL_VALUE}" >&2
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
exit 1
fi
TRANSCRIPT_CHECK="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
| FIRST_PROMPT="${FIRST_PROMPT}" SECOND_PROMPT="${SECOND_PROMPT}" bun -e '
const input = await Bun.stdin.text()
const data = JSON.parse(input)
const userMessages = data.messages.filter((message) => message.type === "user")
const texts = userMessages
.map((message) => {
if (typeof message.content === "string") return message.content
if (Array.isArray(message.content)) {
return message.content
.filter((block) => block && block.type === "text")
.map((block) => block.text || "")
.join("\n")
}
return ""
})
console.log(JSON.stringify({
total: data.messages.length,
userCount: userMessages.length,
hasFirstPrompt: texts.includes(process.env.FIRST_PROMPT),
hasSecondPrompt: texts.includes(process.env.SECOND_PROMPT),
}))
')"
TRANSCRIPT_USER_COUNT="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.userCount)')"
TRANSCRIPT_HAS_FIRST="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasFirstPrompt ? "1" : "0")')"
TRANSCRIPT_HAS_SECOND="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasSecondPrompt ? "1" : "0")')"
if [[ "${TRANSCRIPT_USER_COUNT}" != "1" || "${TRANSCRIPT_HAS_FIRST}" != "1" || "${TRANSCRIPT_HAS_SECOND}" != "0" ]]; then
echo "Expected rewound session to keep only the first user prompt, got ${TRANSCRIPT_CHECK}" >&2
exit 1
fi
${AB} screenshot "${ARTIFACT_DIR}/06-after-second-turn-rewind.png" >/dev/null
echo "Complex rewind E2E passed"
echo "API port: ${API_PORT}"
echo "Web port: ${WEB_PORT}"
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
API_URL="${API_URL:-http://127.0.0.1:3457}"
WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}"
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")"
AB=(agent-browser --session "${SESSION_NAME}")
cleanup() {
local exit_code=$?
"${AB[@]}" close >/dev/null 2>&1 || true
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
}
trap cleanup EXIT
wait_for_text() {
local needle="$1"
if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then
echo "Timed out waiting for page text: ${needle}" >&2
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true
return 1
fi
}
press_escape() {
"${AB[@]}" press Escape >/dev/null 2>&1 || true
}
focus_composer() {
"${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true
}
submit_slash_command() {
local command="$1"
focus_composer
"${AB[@]}" fill textarea "${command}"
"${AB[@]}" press Enter
}
healthcheck() {
curl -fsS "${API_URL}/health" >/dev/null
}
select_plugin_targets() {
DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0))
| sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills))
| .[0].id // empty
')"
ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select(.enabled == true and .componentCounts.skills > 0))
| sort_by(-.componentCounts.skills)
| .[0].id // empty
')"
MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
.plugins
| map(select(.enabled == true and .componentCounts.mcpServers > 0))
| sort_by(-.componentCounts.mcpServers)
| .[0].id // empty
')"
if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then
echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2
exit 1
fi
if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then
echo "No enabled plugin with skills was found in current API data." >&2
exit 1
fi
if [[ -z "${MCP_PLUGIN_ID}" ]]; then
echo "No plugin with MCP servers was found in current API data." >&2
exit 1
fi
DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')"
DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')"
DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')"
DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')"
DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')"
DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')"
DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')"
DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')"
DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')"
DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')"
MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')"
MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')"
MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')"
MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
}
open_plugin_detail() {
local plugin_name="$1"
local escaped_name="${plugin_name//\"/\\\"}"
if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then
wait_for_text "Bundled capabilities"
return 0
fi
echo "Failed to open plugin detail for: ${plugin_name}" >&2
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true
return 1
}
go_back_to_plugin_list() {
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null
wait_for_text "Browse installed plugins"
}
open_settings_sidebar_tab() {
local tab_name="$1"
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null
}
click_visible_card_text() {
local text="$1"
local escaped_text="${text//\"/\\\"}"
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null
}
healthcheck
select_plugin_targets
echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})"
echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})"
echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})"
"${AB[@]}" open "${WEB_URL}"
"${AB[@]}" wait --load networkidle
wait_for_text "Claude Code Haha"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
# Always work from a fresh chat surface so slash-command behavior is deterministic.
"${AB[@]}" find role button click --name "New session"
"${AB[@]}" wait textarea >/dev/null
submit_slash_command "/mcp"
wait_for_text "Available MCP tools"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null
press_escape
"${AB[@]}" wait 300 >/dev/null
submit_slash_command "/skills"
wait_for_text "Available skills"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null
press_escape
"${AB[@]}" wait 300 >/dev/null
submit_slash_command "/plugin"
wait_for_text "Browse installed plugins"
wait_for_text "Plugin Manager"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null
open_plugin_detail "${DETAIL_PLUGIN_NAME}"
wait_for_text "Commands"
wait_for_text "Agents"
wait_for_text "Hooks"
wait_for_text "Skills"
if [[ -n "${DETAIL_COMMAND}" ]]; then
wait_for_text "/${DETAIL_COMMAND}"
fi
if [[ -n "${DETAIL_AGENT}" ]]; then
wait_for_text "${DETAIL_AGENT}"
fi
if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then
wait_for_text "${DETAIL_HOOK_EVENT}"
fi
if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then
wait_for_text "${DETAIL_HOOK_ACTION}"
fi
if [[ -n "${DETAIL_SKILL_NAME}" ]]; then
wait_for_text "/${DETAIL_SKILL_NAME}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null
open_settings_sidebar_tab "Plugins"
wait_for_text "Plugin Detail"
go_back_to_plugin_list
open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}"
if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then
click_visible_card_text "${DETAIL_SKILL_LABEL}"
wait_for_text "Skill metadata"
wait_for_text "${DETAIL_SKILL_LABEL}"
if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then
wait_for_text "${DETAIL_SKILL_DESCRIPTION}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "Skills"
wait_for_text "Skill Browser"
wait_for_text "Plugin"
wait_for_text "${DETAIL_SKILL_LABEL}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null
fi
open_settings_sidebar_tab "Plugins"
wait_for_text "Plugin Detail"
if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then
click_visible_card_text "${DETAIL_AGENT_LABEL}"
wait_for_text "Agent Profile"
wait_for_text "${DETAIL_AGENT}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "Agents"
wait_for_text "Agent Browser"
wait_for_text "Plugin"
wait_for_text "${DETAIL_AGENT}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null
fi
go_back_to_plugin_list
open_plugin_detail "${MCP_PLUGIN_NAME}"
wait_for_text "MCP servers"
if [[ -n "${MCP_SERVER_NAME}" ]]; then
wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}"
fi
if [[ -n "${MCP_SKILL_NAME}" ]]; then
wait_for_text "/${MCP_SKILL_NAME}"
fi
"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null
if [[ -n "${MCP_SERVER_LABEL}" ]]; then
click_visible_card_text "${MCP_SERVER_LABEL}"
wait_for_text "${MCP_SERVER_NAME}"
wait_for_text "Plugin"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null
open_settings_sidebar_tab "MCP"
wait_for_text "MCP servers"
wait_for_text "Plugin"
wait_for_text "${MCP_SERVER_NAME}"
"${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null
fi
echo "agent-browser web UI regression passed"
echo "Artifacts: ${ARTIFACT_DIR}"
@@ -0,0 +1,235 @@
/**
* scan-missing-imports.ts
*
* 在编译 sidecar 之前,扫描 src/ 里所有相对路径的 import / require / 类型 import
* specifier,找出磁盘上不存在的目标,给它们生成最小 stub 文件。
*
* 为什么需要:本 fork 的 src/ 大量使用 ant-internal 的 feature() macro 配
* dynamic require/import,gating 一堆只在 Anthropic 内部 build 才存在的源文件。
* bun build --compile 在 DCE 之前必须先把所有 import specifier 都 resolve 到
* 实际文件,找不到就直接 fail。
*
* Stub 文件内容是一个 Proxy,任何属性读、函数调用、构造调用都返回安全 noop。
* 由于这些代码路径都被 feature(...) === false 的 DCE 干掉了,stub 在运行时
* 永远不会真的被求值 —— 它只是给 resolver 的"占位符"。
*
* 生成的 stub 标记 `// @generated stub from scan-missing-imports` 让脚本可以
* 安全地覆写它们而不会动到真实代码。
*/
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dir, '../..')
const srcRoot = path.join(repoRoot, 'src')
const adaptersRoot = path.join(repoRoot, 'adapters')
// 扫描 + 创建 stub 时允许的根目录。stub 写到这些目录之外会被拒绝,
// 防止意外往 node_modules / 系统路径写文件。
const ALLOWED_STUB_ROOTS = [srcRoot, adaptersRoot]
const STUB_MARKER_TS = '// @generated stub from scan-missing-imports'
const STUB_MARKER_TEXT = '<!-- @generated stub from scan-missing-imports -->'
const TS_STUB_CONTENT = `${STUB_MARKER_TS}
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
// bun build resolver 的占位符。
const __target = function noop() {}
const __handler: ProxyHandler<any> = {
get(_t, prop) {
if (prop === '__esModule') return true
if (prop === 'default') return new Proxy(__target, __handler)
if (prop === Symbol.toPrimitive) return () => undefined
if (prop === Symbol.iterator) return function* () {}
if (prop === Symbol.asyncIterator) return async function* () {}
if (prop === 'then') return undefined
return new Proxy(__target, __handler)
},
apply() {
return new Proxy(__target, __handler)
},
construct() {
return new Proxy(__target, __handler)
},
}
const stub: any = new Proxy(__target, __handler)
export default stub
export const __stubMissing = true
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
export const createCachedMCState = stub
export const isCachedMicrocompactEnabled = stub
export const isModelSupportedForCacheEditing = stub
export const getCachedMCConfig = stub
export const markToolsSentToAPI = stub
export const resetCachedMCState = stub
export const checkProtectedNamespace = stub
export const getCoordinatorUserContext = stub
`
// 文本类资源(.md / .txt / .json 等)通过 Bun 的 text/json loader 内联,
// stub 内容只要是合法的对应格式且非空即可。
const TEXT_STUB_CONTENT = `${STUB_MARKER_TEXT}\nstub\n`
const JSON_STUB_CONTENT = `{"__stubMissing": true}\n`
const TEXT_EXTS = new Set(['.md', '.markdown', '.txt'])
const JSON_EXTS = new Set(['.json', '.json5'])
const IMPORT_PATTERNS = [
// import X from './foo'
/from\s+['"](\.[^'"]+)['"]/g,
// import('./foo')
/import\s*\(\s*['"](\.[^'"]+)['"]/g,
// require('./foo')
/require\s*\(\s*['"](\.[^'"]+)['"]/g,
// import './foo' (side-effect only)
/import\s+['"](\.[^'"]+)['"]/g,
// typeof import('./foo')
/typeof\s+import\s*\(\s*['"](\.[^'"]+)['"]/g,
]
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'])
async function* walk(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
if (entry.name === 'node_modules') continue
if (entry.name === '__tests__') continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
yield* walk(full)
} else if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name))) {
yield full
}
}
}
function resolveCandidates(importer: string, spec: string): string[] {
const importerDir = path.dirname(importer)
const base = path.resolve(importerDir, spec)
return [
base,
base + '.ts',
base + '.tsx',
base + '.mts',
base + '.cts',
base + '.js',
base + '.jsx',
base + '.mjs',
base + '.cjs',
base.replace(/\.(m|c)?js$/, '.ts'),
base.replace(/\.(m|c)?js$/, '.tsx'),
path.join(base, 'index.ts'),
path.join(base, 'index.tsx'),
path.join(base, 'index.js'),
]
}
function pickStubPath(importer: string, spec: string): string {
const importerDir = path.dirname(importer)
const base = path.resolve(importerDir, spec)
// 把 .js 还原成 .ts —— TS 源里写 .js 是 ESM-on-Node 的惯例
if (base.endsWith('.js')) return base.slice(0, -3) + '.ts'
if (base.endsWith('.jsx')) return base.slice(0, -4) + '.tsx'
if (path.extname(base) === '') return base + '.ts'
return base
}
function pickStubContent(stubPath: string): { content: string; marker: string } {
const ext = path.extname(stubPath).toLowerCase()
if (TEXT_EXTS.has(ext)) {
return { content: TEXT_STUB_CONTENT, marker: STUB_MARKER_TEXT }
}
if (JSON_EXTS.has(ext)) {
return { content: JSON_STUB_CONTENT, marker: '"__stubMissing"' }
}
return { content: TS_STUB_CONTENT, marker: STUB_MARKER_TS }
}
async function* walkRoots(roots: string[]): AsyncGenerator<string> {
for (const root of roots) {
if (!existsSync(root)) continue
yield* walk(root)
}
}
async function main() {
const missing = new Map<string, Set<string>>() // stubPath → set of importers
let scannedFiles = 0
for await (const file of walkRoots([srcRoot, adaptersRoot])) {
scannedFiles++
let contents: string
try {
contents = await readFile(file, 'utf8')
} catch {
continue
}
for (const pattern of IMPORT_PATTERNS) {
pattern.lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(contents)) !== null) {
const spec = match[1]!
if (!spec.startsWith('.')) continue
const candidates = resolveCandidates(file, spec)
let exists = false
for (const c of candidates) {
if (existsSync(c)) {
exists = true
break
}
}
if (exists) continue
const stubPath = pickStubPath(file, spec)
if (!missing.has(stubPath)) missing.set(stubPath, new Set())
missing.get(stubPath)!.add(path.relative(repoRoot, file))
}
}
}
console.log(`[scan] scanned ${scannedFiles} source files`)
console.log(`[scan] missing ${missing.size} stub targets`)
let createdCount = 0
let skippedCount = 0
for (const [stubPath, importers] of missing) {
// 安全检查:只在 ALLOWED_STUB_ROOTS(src/、adapters/)下创建,
// 且如果文件已存在但不是 stub 就跳过
const isAllowed = ALLOWED_STUB_ROOTS.some(
(root) => stubPath.startsWith(root + path.sep),
)
if (!isAllowed) {
console.warn(`[scan] skip out-of-tree stub target: ${stubPath}`)
continue
}
const { content, marker } = pickStubContent(stubPath)
if (existsSync(stubPath)) {
try {
const existing = await readFile(stubPath, 'utf8')
if (!existing.includes(marker) && !existing.includes(STUB_MARKER_TS)) {
console.warn(
`[scan] skip non-stub existing file: ${path.relative(repoRoot, stubPath)}`,
)
skippedCount++
continue
}
} catch {
// ignore
}
}
await mkdir(path.dirname(stubPath), { recursive: true })
await writeFile(stubPath, content, 'utf8')
createdCount++
const rel = path.relative(repoRoot, stubPath)
const sample = [...importers].slice(0, 2).join(', ')
console.log(
`[scan] stub: ${rel} (referenced from ${sample}${importers.size > 2 ? `, +${importers.size - 2}` : ''})`,
)
}
console.log(`[scan] created ${createdCount} stubs, skipped ${skippedCount}`)
}
await main()
@@ -0,0 +1,138 @@
/**
* Claude Code 桌面端合并 sidecar 入口。
*
* 历史上 server / cli / IM adapters 是各自独立的进程。每个 bun-compile
* 二进制都要带一份 ~55MB 的 bun runtime,光这一项就重复占了 100MB+。
* 把所有运行模式合并到同一个二进制里,runtime 只保留一份;调用方通过
* 第一个 positional 参数选择模式:
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [其它 CLI 参数...]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram]
*
* 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的
* 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及
* adapters/feishu/index.ts 等顶层都会立即读 process.argv / process.env,
* 必须在它们求值前 splice 掉 --app-root、mode、--feishu/--telegram 这些
* launcher-only 参数。
*/
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
const rawArgs = process.argv.slice(2)
const invocation = resolveSidecarInvocation(rawArgs)
if (!invocation.mode) {
console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")')
process.exit(2)
}
const mode = invocation.mode
const restArgs = invocation.restArgs
if (mode === 'adapters') {
await runAdapters(restArgs)
} else {
const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot)
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CALLER_DIR ||= process.cwd()
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
await import('../../preload.ts')
if (mode === 'server') {
const { startServer } = await import('../../src/server/index.ts')
startServer()
} else if (mode === 'cli') {
await import('../../src/entrypoints/cli.tsx')
} else {
console.error(`claude-sidecar: unknown mode "${mode}" (expected "server", "cli" or "adapters")`)
process.exit(2)
}
}
async function runAdapters(rawArgs: string[]): Promise<void> {
// adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu /
// --telegram 选择启用哪个适配器,再加可选的 --app-root(透传给
// adapters/common/config.ts 内的 process.env 读取)。
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
let enableFeishu = false
let enableTelegram = false
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i]
if (arg === '--app-root') {
appRoot = rawArgs[i + 1] ?? null
i += 1
continue
}
if (arg === '--feishu') {
enableFeishu = true
continue
}
if (arg === '--telegram') {
enableTelegram = true
continue
}
console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`)
}
if (!enableFeishu && !enableTelegram) {
console.error(
'claude-sidecar adapters: must enable at least one of --feishu / --telegram',
)
process.exit(2)
}
if (appRoot) {
process.env.CLAUDE_APP_ROOT = appRoot
}
process.env.CALLER_DIR ||= process.cwd()
await import('../../preload.ts')
// 在 import adapter 之前先用同一份 loadConfig() 检查凭据。adapter 的
// top-level 代码里已经有 if (!cred) process.exit(1),但那会把整个
// 进程拖死 —— 包括另一个本来正常的 adapter。这里提前 gate 一下,
// 缺凭据的 adapter 直接跳过、不 import。
const { loadConfig } = await import('../../adapters/common/config.ts')
const config = loadConfig()
let started = 0
if (enableFeishu) {
if (!config.feishu.appId || !config.feishu.appSecret) {
console.warn(
'[claude-sidecar] --feishu requested but FEISHU_APP_ID / FEISHU_APP_SECRET missing in env or ~/.claude/adapters.json — skipping',
)
} else {
console.log('[claude-sidecar] starting Feishu adapter')
// 副作用 import:feishu/index.ts 顶层会自动 new WSClient + start()
await import('../../adapters/feishu/index.ts')
started += 1
}
}
if (enableTelegram) {
if (!config.telegram.botToken) {
console.warn(
'[claude-sidecar] --telegram requested but TELEGRAM_BOT_TOKEN missing in env or ~/.claude/adapters.json — skipping',
)
} else {
console.log('[claude-sidecar] starting Telegram adapter')
// 副作用 import:telegram/index.ts 顶层会自动 bot.start()
await import('../../adapters/telegram/index.ts')
started += 1
}
}
if (started === 0) {
console.error(
'[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json',
)
process.exit(1)
}
// 让进程保持存活:两个 adapter 都通过 long-lived WebSocket(Lark WSClient
// / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外
// setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
describe('resolveSidecarInvocation', () => {
it('keeps explicit sidecar modes unchanged', () => {
expect(
resolveSidecarInvocation(
['server', '--host', '127.0.0.1'],
'/tmp/claude-sidecar',
),
).toEqual({
mode: 'server',
restArgs: ['--host', '127.0.0.1'],
defaultAppRoot: null,
})
})
it('defaults claude-haha invocations to cli mode', () => {
expect(
resolveSidecarInvocation(
['plugin', 'install', 'demo'],
'/Users/demo/.local/bin/claude-haha',
),
).toEqual({
mode: 'cli',
restArgs: ['plugin', 'install', 'demo'],
defaultAppRoot: '/Users/demo/.local/bin',
})
})
})
describe('parseLauncherArgs', () => {
it('falls back to the provided default app root', () => {
expect(
parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'),
).toEqual({
appRoot: '/Users/demo/.local/bin',
args: ['plugin', 'install', 'demo'],
})
})
it('lets explicit app root override the default', () => {
expect(
parseLauncherArgs(
['--app-root', '/tmp/app', 'plugin', 'install', 'demo'],
'/Users/demo/.local/bin',
),
).toEqual({
appRoot: '/tmp/app',
args: ['plugin', 'install', 'demo'],
})
})
})
@@ -0,0 +1,64 @@
import path from 'node:path'
export type SidecarMode = 'server' | 'cli' | 'adapters'
const EXPLICIT_MODES = new Set<SidecarMode>(['server', 'cli', 'adapters'])
const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe'])
export function resolveSidecarInvocation(
rawArgs: string[],
execPath: string = process.execPath,
envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
): {
mode: SidecarMode | null
restArgs: string[]
defaultAppRoot: string | null
} {
const explicitMode = rawArgs[0]
if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) {
return {
mode: explicitMode as SidecarMode,
restArgs: rawArgs.slice(1),
defaultAppRoot: envAppRoot,
}
}
const execName = path.basename(execPath).toLowerCase()
if (DESKTOP_CLI_NAMES.has(execName)) {
return {
mode: 'cli',
restArgs: rawArgs,
defaultAppRoot: envAppRoot ?? path.dirname(execPath),
}
}
return {
mode: null,
restArgs: rawArgs,
defaultAppRoot: envAppRoot,
}
}
export function parseLauncherArgs(
rawArgs: string[],
defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
): { appRoot: string; args: string[] } {
const nextArgs: string[] = []
let appRoot: string | null = defaultAppRoot
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg!)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-sidecar')
}
return { appRoot, args: nextArgs }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
[package]
name = "heicode-desktop"
version = "0.1.0"
edition = "2021"
[lib]
name = "heicode_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1.0.102"
portable-pty = "0.9.0"
@@ -0,0 +1,42 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="panel" x1="190" y1="168" x2="842" y2="860" gradientUnits="userSpaceOnUse">
<stop stop-color="#231916"/>
<stop offset="1" stop-color="#18110F"/>
</linearGradient>
<linearGradient id="panelGlow" x1="256" y1="172" x2="744" y2="852" gradientUnits="userSpaceOnUse">
<stop stop-color="#7A3B20"/>
<stop offset="1" stop-color="#2B1813"/>
</linearGradient>
<radialGradient id="spark" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
<stop stop-color="#FFD56A"/>
<stop offset="1" stop-color="#F6B23E"/>
</radialGradient>
<filter id="sparkGlow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="18" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
<feBlend in="SourceGraphic"/>
</filter>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
<linearGradient id="sheen" x1="244" y1="224" x2="448" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.22"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
<path d="M214 760C366 824 566 824 718 754" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="370" cy="292" rx="136" ry="92" fill="url(#sheen)"/>
<path d="M310 350L510 512L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
<path d="M684 298H834V726H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#sparkGlow)">
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,16 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 772 B

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,106 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-capabilities-schema-v2.json",
"identifier": "default",
"description": "Default capabilities for Claude Code Desktop",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:allow-open",
{
"identifier": "shell:allow-execute",
"allow": [
{
"args": [
"server",
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"adapters",
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"cli",
{ "validator": "regex", "value": "^.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
}
]
},
{
"identifier": "shell:allow-spawn",
"allow": [
{
"args": [
"server",
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"adapters",
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"cli",
{ "validator": "regex", "value": "^.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
}
]
},
{
"identifier": "shell:allow-kill",
"allow": [
{
"args": [
"server",
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"adapters",
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
},
{
"args": [
"cli",
{ "validator": "regex", "value": "^.+$" }
],
"name": "binaries/claude-sidecar",
"sidecar": true
}
]
},
"dialog:allow-open",
"dialog:allow-save",
"process:allow-exit",
"process:allow-restart",
"updater:default"
]
}
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Claude Code Desktop","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":["server",{"validator":"regex","value":"^(--port|--host|--config)=.+$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["adapters",{"validator":"regex","value":"^(feishu|telegram|lark|slack)(:.+)?$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["cli",{"validator":"regex","value":"^.+$"}],"name":"binaries/claude-sidecar","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":["server",{"validator":"regex","value":"^(--port|--host|--config)=.+$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["adapters",{"validator":"regex","value":"^(feishu|telegram|lark|slack)(:.+)?$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["cli",{"validator":"regex","value":"^.+$"}],"name":"binaries/claude-sidecar","sidecar":true}]},{"identifier":"shell:allow-kill","allow":[{"args":["server",{"validator":"regex","value":"^(--port|--host|--config)=.+$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["adapters",{"validator":"regex","value":"^(feishu|telegram|lark|slack)(:.+)?$"}],"name":"binaries/claude-sidecar","sidecar":true},{"args":["cli",{"validator":"regex","value":"^.+$"}],"name":"binaries/claude-sidecar","sidecar":true}]},"dialog:allow-open","dialog:allow-save","process:allow-exit","process:allow-restart","updater:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More