feat: add IM adapter integration (Telegram + Feishu) with web settings UI

Implement IM adapters allowing users to chat with Claude Code from Telegram
and Feishu/Lark. Includes persistent session management (chatId→sessionId
mapping), project selection via /projects command, and a web UI settings page
for configuring bot tokens, allowed users, and default project directory.

Key changes:
- adapters/: Telegram and Feishu adapter scripts with shared common modules
  (WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting)
- Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking
- Frontend: AdapterSettings page in Settings tab with i18n support
- DirectoryPicker: use React Portal for dropdown to fix overflow clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-08 19:38:51 +08:00
parent 983d83f86b
commit 82e6e27687
37 changed files with 4833 additions and 10 deletions

258
adapters/README.md Normal file
View File

@ -0,0 +1,258 @@
# Claude Code IM Adapters
通过 Telegram / 飞书与 Claude Code Desktop 对话。
每个 Adapter 是一个轻量独立脚本,直连 Desktop 服务端的 WebSocket 接口,与桌面 UI 走完全相同的协议。
## 架构
```
Telegram / 飞书
↕ (各平台 SDK)
IM Adapter (独立脚本)
↕ ws://localhost:3456/ws/{sessionId}
Claude Code Desktop 服务端 (已有,零改动)
CLI 子进程 (Claude Code Agent)
```
## 前置条件
- **Claude Code Desktop** 已运行(服务端默认在 `localhost:3456`
- **Bun** 运行时 `>= 1.0`
## 安装
```bash
cd adapters
bun install
```
---
## Telegram
### 1. 创建 Bot
1. 在 Telegram 中找到 [@BotFather](https://t.me/BotFather)
2. 发送 `/newbot`,按提示创建
3. 获取 Bot Token格式`123456:ABC-DEF...`
### 2. 配置
**方式一:环境变量(推荐)**
```bash
export TELEGRAM_BOT_TOKEN="你的Bot Token"
```
**方式二:配置文件**
编辑 `~/.claude/adapters.json`
```json
{
"telegram": {
"botToken": "你的Bot Token",
"allowedUsers": [],
"defaultWorkDir": "/path/to/your/project"
}
}
```
- `allowedUsers`Telegram User ID 白名单。留空 `[]` 允许所有人(适合个人使用)
- `defaultWorkDir`Claude Code 的工作目录
### 3. 启动
```bash
cd adapters
bun run telegram
```
### 4. 使用
在 Telegram 中找到你的 Bot直接发消息即可。
**命令:**
| 命令 | 说明 |
|------|------|
| `/start` | 显示帮助信息 |
| `/new` | 新建会话(重置上下文) |
| `/stop` | 停止当前生成 |
**权限审批:** 当 Claude 需要执行敏感操作如运行终端命令、写文件Bot 会发送带按钮的消息,点击 **允许****拒绝** 即可。
---
## 飞书 (Feishu)
### 1. 创建应用
1. 打开 [飞书开放平台](https://open.feishu.cn/app)
2. 创建「企业自建应用」
3. 记下 **App ID** 和 **App Secret**
### 2. 配置应用权限
在应用后台「权限管理」中开启:
- `im:message` — 获取与发送消息
- `im:message:send_as_bot` — 以机器人身份发送消息
- `im:resource` — 获取消息中的资源文件
### 3. 配置事件订阅
在应用后台「事件订阅」中:
1. 选择「使用长连接接收事件」(WebSocket 模式,无需公网地址)
2. 添加事件:
- `im.message.receive_v1` — 接收消息
- `card.action.trigger` — 卡片按钮点击回调
### 4. 配置 Adapter
**方式一:环境变量(推荐)**
```bash
export FEISHU_APP_ID="cli_xxx"
export FEISHU_APP_SECRET="你的App Secret"
```
**方式二:配置文件**
编辑 `~/.claude/adapters.json`
```json
{
"feishu": {
"appId": "cli_xxx",
"appSecret": "你的App Secret",
"allowedUsers": [],
"defaultWorkDir": "/path/to/your/project",
"streamingCard": false
}
}
```
- `allowedUsers`:飞书 Open ID 白名单。留空 `[]` 允许所有人
- `streamingCard`:启用流式卡片模式(实时更新消息内容,体验更好)
### 5. 发布应用
在应用后台「版本管理与发布」中创建版本并发布(至少发布到「开发中」状态,机器人才能收发消息)。
### 6. 启动
```bash
cd adapters
bun run feishu
```
### 7. 使用
- **私聊**:直接给机器人发消息
- **群聊**:需要 @机器人
- **新会话**:发送 `/new``新会话`
- **停止**:发送 `/stop``停止`
**权限审批:** 当 Claude 需要执行敏感操作Bot 会发送交互式卡片,点击 **允许****拒绝** 按钮。
---
## 配置文件完整示例
`~/.claude/adapters.json`
```json
{
"serverUrl": "ws://127.0.0.1:3456",
"telegram": {
"botToken": "123456:ABC-DEF...",
"allowedUsers": [123456789, 987654321],
"defaultWorkDir": "/Users/me/workspace/my-project"
},
"feishu": {
"appId": "cli_xxx",
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
"allowedUsers": ["ou_xxx"],
"defaultWorkDir": "/Users/me/workspace/my-project",
"streamingCard": false
}
}
```
环境变量优先级高于配置文件。
---
## 常见问题
### 连不上服务器
```
[WsBridge] Error on tg-xxx: connect ECONNREFUSED 127.0.0.1:3456
```
**原因**Claude Code Desktop 没有运行。启动 Desktop App 后再启动 Adapter。
### Telegram Bot 无响应
1. 检查 Bot Token 是否正确
2. 检查 Bot 是否被 Telegram 封禁(找 @BotFather 查看状态)
3. 如果配置了 `allowedUsers`,确认你的 User ID 在列表中
**获取你的 Telegram User ID**:给 [@userinfobot](https://t.me/userinfobot) 发条消息。
### 飞书收不到消息
1. 检查 App ID / App Secret 是否正确
2. 检查应用是否已发布
3. 检查事件订阅是否配置了 `im.message.receive_v1`
4. 检查应用权限是否已开启 `im:message`
5. 群聊中确认已 @机器人
### 权限按钮不响应
- **Telegram**:可能是 callback_data 格式问题,查看 Adapter 日志
- **飞书**:检查事件订阅是否配置了 `card.action.trigger`
---
## 开发
### 运行测试
```bash
cd adapters
bun test # 全部测试
bun test common/ # 公共模块测试
bun test telegram/ # Telegram 测试
bun test feishu/ # 飞书测试
```
### 目录结构
```
adapters/
├── common/ # 公共模块
│ ├── ws-bridge.ts # WebSocket 桥接
│ ├── message-buffer.ts # 流式消息缓冲
│ ├── message-dedup.ts # 消息去重
│ ├── chat-queue.ts # 会话串行队列
│ ├── format.ts # 消息格式化
│ ├── config.ts # 配置加载
│ └── __tests__/ # 公共模块测试
├── telegram/
│ ├── index.ts # Telegram Adapter 入口
│ └── __tests__/ # Telegram 测试
├── feishu/
│ ├── index.ts # 飞书 Adapter 入口
│ └── __tests__/ # 飞书测试
├── package.json
├── tsconfig.json
└── README.md
```

141
adapters/bun.lock Normal file
View File

@ -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=="],
}
}

View File

@ -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])
})
})

View File

@ -0,0 +1,100 @@
import { describe, it, expect } from 'bun:test'
import {
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')
})
})

View File

@ -0,0 +1,66 @@
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()
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { WsBridge } from '../ws-bridge.js'
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)
})
})

View File

@ -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
}

73
adapters/common/config.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* Adapter
*
* > ~/.claude/adapters.json >
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
export type TelegramConfig = {
botToken: string
allowedUsers: number[]
defaultWorkDir: string
}
export type FeishuConfig = {
appId: string
appSecret: string
encryptKey: string
verificationToken: string
allowedUsers: string[]
defaultWorkDir: string
streamingCard: boolean
}
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string
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 ?? {}
return {
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
defaultProjectDir: file.defaultProjectDir || '',
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
allowedUsers: tg.allowedUsers ?? [],
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 ?? [],
defaultWorkDir: fs_.defaultWorkDir || process.cwd(),
streamingCard: fs_.streamingCard ?? false,
},
}
}

59
adapters/common/format.ts Normal file
View File

@ -0,0 +1,59 @@
/**
*
*/
/** 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 preview = truncateInput(input, 200)
return `🔧 ${toolName}\n${preview}`
}
/** 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')
}

View File

@ -0,0 +1,44 @@
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export class AdapterHttpClient {
readonly httpBaseUrl: string
constructor(wsUrl: string) {
this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/$/, '')
}
async createSession(workDir: string): Promise<string> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
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
}
async listRecentProjects(): Promise<RecentProject[]> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
if (!res.ok) {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
}
}

View File

@ -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)
}
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,196 @@
/**
* WebSocket Bridge
*
* Claude Code Desktop /ws/:sessionId
* chatId sessionId
*/
import WebSocket from 'ws'
/** 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>()
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): boolean {
return this.send(chatId, { type: 'user_message', content })
}
/** Respond to a permission request. */
sendPermissionResponse(chatId: string, requestId: string, allowed: boolean): boolean {
return this.send(chatId, { type: 'permission_response', requestId, allowed })
}
/** 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)
}
/** 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()
}
// ------- 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) => {
try {
const msg: ServerMessage = JSON.parse(raw.toString())
if (msg.type === 'pong') return
const handler = this.handlers.get(chatId)
if (handler) handler(msg)
} catch (err) {
console.error('[WsBridge] Parse error:', err)
}
})
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)
})
}
private send(chatId: string, message: Record<string, unknown>): boolean {
const session = this.sessions.get(chatId)
if (!session || session.ws.readyState !== WebSocket.OPEN) 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)
}
}

View File

@ -0,0 +1,234 @@
/**
* Adapter
*
* Bot
*/
import { describe, it, expect } from 'bun:test'
// ---------- 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()
}
function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record<string, unknown> {
const preview = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const truncated = preview.length > 300 ? preview.slice(0, 300) + '…' : preview
return {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
template: 'orange',
},
elements: [
{
tag: 'markdown',
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
value: { action: 'permit', requestId, allowed: true },
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
}
}
// ---------- 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', () => {
it('builds valid card structure', () => {
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abcde')
expect(card.schema).toBe('2.0')
expect((card.header as any).title.content).toContain('权限确认')
expect((card.elements as any[]).length).toBe(2) // markdown + action
const actionElement = (card.elements as any[])[1]
expect(actionElement.tag).toBe('action')
expect(actionElement.actions.length).toBe(2) // allow + deny buttons
})
it('allow button has correct value', () => {
const card = buildPermissionCard('Read', {}, 'xyz12')
const allowBtn = (card.elements as any[])[1].actions[0]
expect(allowBtn.value.action).toBe('permit')
expect(allowBtn.value.requestId).toBe('xyz12')
expect(allowBtn.value.allowed).toBe(true)
})
it('deny button has correct value', () => {
const card = buildPermissionCard('Read', {}, 'xyz12')
const denyBtn = (card.elements as any[])[1].actions[1]
expect(denyBtn.value.action).toBe('permit')
expect(denyBtn.value.requestId).toBe('xyz12')
expect(denyBtn.value.allowed).toBe(false)
})
it('truncates long input preview', () => {
const longInput = { command: 'x'.repeat(500) }
const card = buildPermissionCard('Bash', longInput, 'abc')
const mdElement = (card.elements as any[])[0]
expect(mdElement.content).toContain('…')
})
})
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('ignores non-permit actions', () => {
const event = {
action: { value: { action: 'other_action' } },
}
expect(event.action.value.action).not.toBe('permit')
})
})

551
adapters/feishu/index.ts Normal file
View File

@ -0,0 +1,551 @@
/**
* (Feishu/Lark) Adapter for Claude Code Desktop
*
* @larksuiteoapi/node-sdk Bot /ws/:sessionId
* 使 WebSocket
*
* FEISHU_APP_ID=xxx FEISHU_APP_SECRET=xxx bun run feishu/index.ts
*/
import * as Lark from '@larksuiteoapi/node-sdk'
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 { splitMessage, formatToolUse, formatPermissionRequest, truncateInput } from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
// ---------- init ----------
const config = loadConfig()
if (!config.feishu.appId || !config.feishu.appSecret) {
console.error('[Feishu] Missing FEISHU_APP_ID / FEISHU_APP_SECRET. Set env or ~/.claude/adapters.json')
process.exit(1)
}
const larkClient = new Lark.Client({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
appType: Lark.AppType.SelfBuild,
domain: Lark.Domain.Feishu,
})
const bridge = new WsBridge(config.serverUrl, 'feishu')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
// Track state per chat
type ChatState = {
cardId?: string
sequence: number
replyMessageId?: string
}
const chatStates = new Map<string, ChatState>()
const buffers = new Map<string, MessageBuffer>()
const accumulatedText = new Map<string, string>()
const pendingProjectSelection = new Map<string, boolean>()
// Bot's own open_id (resolved on first message)
let botOpenId: string | null = null
// WSClient reference for graceful shutdown
let wsClient: InstanceType<typeof Lark.WSClient> | null = null
// ---------- helpers ----------
function isAllowed(openId: string): boolean {
const list = config.feishu.allowedUsers
return list.length === 0 || list.includes(openId)
}
function getChatState(chatId: string): ChatState {
let state = chatStates.get(chatId)
if (!state) {
state = { sequence: 0 }
chatStates.set(chatId, state)
}
return state
}
function getBuffer(chatId: string): MessageBuffer {
let buf = buffers.get(chatId)
if (!buf) {
buf = new MessageBuffer(async (text, isComplete) => {
await flushToFeishu(chatId, text, isComplete)
})
buffers.set(chatId, buf)
}
return buf
}
/** Send a text message (post format). */
async function sendText(chatId: string, text: string, replyToMessageId?: string): Promise<string | undefined> {
const content = JSON.stringify({
zh_cn: { content: [[{ tag: 'md', text }]] },
})
try {
if (replyToMessageId) {
const resp = await larkClient.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'post' },
})
return resp.data?.message_id
}
const resp = await larkClient.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'post' as const,
content,
},
})
return resp.data?.message_id
} catch (err) {
console.error('[Feishu] Send text error:', err)
return undefined
}
}
/** Send an interactive card (for permission requests). */
async function sendCard(chatId: string, card: Record<string, unknown>): Promise<string | undefined> {
try {
const resp = await larkClient.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'interactive',
content: JSON.stringify(card),
},
})
return resp.data?.message_id
} catch (err) {
console.error('[Feishu] Send card error:', err)
return undefined
}
}
/** Update a message's content (patch). */
async function patchMessage(messageId: string, text: string): Promise<void> {
try {
await larkClient.im.message.patch({
path: { message_id: messageId },
data: {
content: JSON.stringify({
zh_cn: { content: [[{ tag: 'md', text }]] },
}),
},
})
} catch {
// patch may fail if message format changed — ignore
}
}
/** Build a permission request card. */
function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record<string, unknown> {
const truncated = truncateInput(input, 300)
return {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
template: 'orange',
},
elements: [
{
tag: 'markdown',
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
value: { action: 'permit', requestId, allowed: true },
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
value: { action: 'permit', requestId, allowed: false },
},
],
},
],
}
}
async function flushToFeishu(chatId: string, newText: string, isComplete: boolean): Promise<void> {
const prev = accumulatedText.get(chatId) ?? ''
const fullText = prev + newText
accumulatedText.set(chatId, fullText)
const state = getChatState(chatId)
if (state.replyMessageId) {
const displayText = fullText + (isComplete ? '' : ' ▍')
await patchMessage(state.replyMessageId, displayText)
}
if (isComplete) {
if (!state.replyMessageId && fullText.trim()) {
const chunks = splitMessage(fullText, 30000)
for (const chunk of chunks) {
await sendText(chatId, chunk)
}
}
accumulatedText.delete(chatId)
chatStates.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 true
}
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> {
try {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
} catch (err) {
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await sendText(chatId,
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。')
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 sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`)
} catch (err) {
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ---------- server message handler ----------
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const buf = getBuffer(chatId)
const state = getChatState(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
if (msg.state === 'thinking' && !state.replyMessageId) {
const mid = await sendText(chatId, '💭 思考中...')
if (mid) {
state.replyMessageId = mid
accumulatedText.set(chatId, '')
}
}
break
case 'content_start':
if (msg.blockType === 'text' && !state.replyMessageId) {
const mid = await sendText(chatId, '▍')
if (mid) {
state.replyMessageId = mid
accumulatedText.set(chatId, '')
}
}
break
case 'content_delta':
if (msg.text) {
buf.append(msg.text)
}
break
case 'thinking':
if (state.replyMessageId) {
await patchMessage(state.replyMessageId, `💭 ${msg.text.slice(0, 500)}...`)
}
break
case 'tool_use_complete': {
const info = formatToolUse(msg.toolName, msg.input)
await sendText(chatId, info)
break
}
case 'tool_result':
if (msg.isError) {
await sendText(chatId, '❌ 工具执行失败')
}
break
case 'permission_request': {
const card = buildPermissionCard(msg.toolName, msg.input, msg.requestId)
await sendCard(chatId, card)
break
}
case 'message_complete':
await buf.complete()
break
case 'error':
await sendText(chatId, `${msg.message}`)
break
}
}
// ---------- extract message text ----------
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 } }>): 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()
}
// ---------- event handlers ----------
async function handleMessage(data: any): Promise<void> {
const event = data as {
sender?: { sender_id?: { open_id?: string } }
message?: {
message_id?: string
chat_id?: string
chat_type?: string
content?: string
message_type?: string
mentions?: Array<{ id?: { open_id?: string }; name?: string }>
}
}
const messageId = event.message?.message_id
const chatId = event.message?.chat_id
const senderOpenId = event.sender?.sender_id?.open_id
const chatType = event.message?.chat_type
const content = event.message?.content
const msgType = event.message?.message_type
if (!messageId || !chatId || !senderOpenId || !content || !msgType) return
if (!dedup.tryRecord(messageId)) return
if (!isAllowed(senderOpenId)) return
if (chatType === 'group') {
if (!isBotMentioned(event.message?.mentions)) return
}
let text = extractText(content, msgType)
if (!text) return
text = stripMentions(text)
if (!text) return
// Handle commands
if (text === '/new' || text === '新会话') {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
chatStates.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
await showProjectPicker(chatId)
return
}
if (text === '/stop' || text === '停止') {
bridge.sendStopGeneration(chatId)
await sendText(chatId, '⏹ 已发送停止信号。')
return
}
if (text === '/projects' || text === '项目列表') {
await showProjectPicker(chatId)
return
}
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await sendText(chatId, '请输入有效的编号。')
return
}
// Normal message flow
enqueue(chatId, async () => {
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text!)
}
})
}
async function handleCardAction(data: any): Promise<any> {
const event = data as {
operator?: { open_id?: string }
action?: { value?: { action?: string; requestId?: string; allowed?: boolean } }
context?: { open_chat_id?: string }
}
const action = event.action?.value?.action
if (action !== 'permit') return
const requestId = event.action?.value?.requestId
const allowed = event.action?.value?.allowed ?? false
const chatId = event.context?.open_chat_id
if (!requestId || !chatId) return
bridge.sendPermissionResponse(chatId, requestId, allowed)
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
await sendText(chatId, statusText)
return {
toast: { type: 'info', content: statusText },
}
}
// ---------- resolve bot identity ----------
async function resolveBotOpenId(retries = 3): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const resp = await larkClient.contact.user.get({
path: { user_id: 'me' },
params: { user_id_type: 'open_id' },
})
botOpenId = (resp.data?.user as any)?.open_id ?? null
if (botOpenId) {
console.log(`[Feishu] Bot open_id: ${botOpenId}`)
return
}
} catch {
if (i < retries - 1) {
console.warn(`[Feishu] Could not resolve bot open_id, retrying (${i + 1}/${retries})...`)
await new Promise((r) => setTimeout(r, 2000 * (i + 1)))
}
}
}
console.warn('[Feishu] Could not resolve bot open_id (group @mention check may not work)')
}
// ---------- start ----------
async function start(): Promise<void> {
console.log('[Feishu] Starting bot...')
console.log(`[Feishu] Server: ${config.serverUrl}`)
console.log(`[Feishu] App ID: ${config.feishu.appId}`)
await resolveBotOpenId()
const dispatcher = new Lark.EventDispatcher({
encryptKey: config.feishu.encryptKey,
verificationToken: config.feishu.verificationToken,
})
dispatcher.register({
'im.message.receive_v1': async (data: any) => {
try {
await handleMessage(data)
} catch (err) {
console.error('[Feishu] Message handler error:', err)
}
},
'card.action.trigger': async (data: any) => {
try {
return await handleCardAction(data)
} catch (err) {
console.error('[Feishu] Card action error:', err)
}
},
} as any)
wsClient = new Lark.WSClient({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
domain: Lark.Domain.Feishu,
loggerLevel: Lark.LoggerLevel.info,
})
await wsClient.start({ eventDispatcher: dispatcher })
console.log('[Feishu] Bot is running! (WebSocket connected)')
}
start().catch((err) => {
console.error('[Feishu] Failed to start:', err)
process.exit(1)
})
process.on('SIGINT', () => {
console.log('[Feishu] Shutting down...')
bridge.destroy()
dedup.destroy()
process.exit(0)
})

23
adapters/package.json Normal file
View File

@ -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"
}
}

View File

@ -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)
})
})
})

342
adapters/telegram/index.ts Normal file
View File

@ -0,0 +1,342 @@
/**
* 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 { 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 { splitMessage, formatToolUse, formatPermissionRequest } from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
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)
// 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>()
// ---------- helpers ----------
function isAllowed(userId: number): boolean {
const list = config.telegram.allowedUsers
return list.length === 0 || list.includes(userId)
}
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
}
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 true
}
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 {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
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')}`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ---------- server message handler ----------
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const numericChatId = Number(chatId)
const buf = getBuffer(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
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' && !placeholders.has(chatId)) {
const sent = await bot.api.sendMessage(numericChatId, '▍')
placeholders.set(chatId, { chatId, messageId: sent.message_id })
accumulatedText.set(chatId, '')
}
break
case 'content_delta':
if (msg.text) {
buf.append(msg.text)
}
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': {
const info = formatToolUse(msg.toolName, msg.input)
await bot.api.sendMessage(numericChatId, info)
break
}
case 'tool_result':
if (msg.isError) {
await bot.api.sendMessage(numericChatId, `❌ 工具执行失败`)
}
break
case 'permission_request': {
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':
await buf.complete()
break
case 'error':
await bot.api.sendMessage(numericChatId, `${msg.message}`)
break
}
}
// ---------- bot handlers ----------
bot.command('start', (ctx) => {
ctx.reply(
'👋 Claude Code Bot 已就绪。\n\n' +
'命令:\n' +
'/projects — 选择/切换项目\n' +
'/new — 新建会话\n' +
'/stop — 停止生成'
)
})
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
bridge.resetSession(chatId)
sessionStore.delete(chatId)
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
await showProjectPicker(chatId)
})
bot.command('projects', async (ctx) => {
const chatId = String(ctx.chat.id)
await showProjectPicker(chatId)
})
bot.command('stop', (ctx) => {
const chatId = String(ctx.chat.id)
bridge.sendStopGeneration(chatId)
ctx.reply('⏹ 已发送停止信号。')
})
bot.on('message:text', (ctx) => {
if (!ctx.from || !isAllowed(ctx.from.id)) return
if (!dedup.tryRecord(String(ctx.message.message_id))) return
const chatId = String(ctx.chat.id)
const text = ctx.message.text
enqueue(chatId, async () => {
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await bot.api.sendMessage(Number(chatId),
`✅ 已选择 ${selected.projectName}。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await bot.api.sendMessage(Number(chatId), '请输入有效的编号。')
return
}
// Normal message flow
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text)
}
})
})
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 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)
})

17
adapters/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"paths": {
"@server/*": ["../src/server/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,12 @@
import { api } from './client'
import type { AdapterFileConfig } from '../types/adapter'
export const adaptersApi = {
getConfig() {
return api.get<AdapterFileConfig>('/api/adapters')
},
updateConfig(patch: Partial<AdapterFileConfig>) {
return api.put<AdapterFileConfig>('/api/adapters', patch)
},
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
@ -23,18 +24,51 @@ export function DirectoryPicker({ value, onChange }: Props) {
const [browsePath, setBrowsePath] = useState('')
const [browseParent, setBrowseParent] = useState('')
const [loading, setLoading] = useState(false)
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
// Close on outside click
const dropdownRef = useRef<HTMLDivElement>(null)
const updateDropdownPos = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const DROPDOWN_HEIGHT = 380 // approximate max height
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
const direction = spaceBelow >= DROPDOWN_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
setDropdownPos({
top: direction === 'down' ? rect.bottom + 4 : rect.top - 4,
left: rect.left,
direction,
})
}, [])
// Close on outside click (checks both trigger and portal dropdown)
useEffect(() => {
if (!isOpen) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false)
const target = e.target as Node
if (ref.current?.contains(target)) return
if (dropdownRef.current?.contains(target)) return
setIsOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [isOpen])
// Recalculate position on scroll/resize while open
useEffect(() => {
if (!isOpen) return
updateDropdownPos()
window.addEventListener('scroll', updateDropdownPos, true)
window.addEventListener('resize', updateDropdownPos)
return () => {
window.removeEventListener('scroll', updateDropdownPos, true)
window.removeEventListener('resize', updateDropdownPos)
}
}, [isOpen, updateDropdownPos])
// Load recent projects when opened
useEffect(() => {
if (!isOpen || mode !== 'recent') return
@ -92,6 +126,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
{/* Trigger — shows selected project chip or placeholder */}
{value ? (
<button
ref={triggerRef}
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]"
>
@ -118,6 +153,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
</button>
) : (
<button
ref={triggerRef}
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
@ -126,9 +162,20 @@ export function DirectoryPicker({ value, onChange }: Props) {
</button>
)}
{/* Dropdown */}
{isOpen && (
<div className="absolute left-0 bottom-full mb-2 w-[400px] z-50 bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden">
{/* Dropdown — rendered via portal to escape overflow clipping */}
{isOpen && dropdownPos && createPortal(
<div
ref={dropdownRef}
className="w-[400px] bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden"
style={{
position: 'fixed',
left: dropdownPos.left,
...(dropdownPos.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - dropdownPos.top }),
zIndex: 9999,
}}
>
{mode === 'recent' ? (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
@ -245,7 +292,8 @@ export function DirectoryPicker({ value, onChange }: Props) {
</div>
</>
)}
</div>
</div>,
document.body
)}
</div>
)

View File

@ -114,7 +114,8 @@ export const en = {
'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow everyone.',
'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321',
'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy',
'settings.adapters.workDir': 'Working Directory',
'settings.adapters.defaultProject': 'Default Project',
'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.',
'settings.adapters.streamingCard': 'Streaming Card Mode',
'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience',
'settings.adapters.serverUrl': 'Server URL',

View File

@ -116,7 +116,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.allowedUsersHint': '逗号分隔。留空允许所有人。',
'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321',
'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy',
'settings.adapters.workDir': '工作目录',
'settings.adapters.defaultProject': '默认项目',
'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。',
'settings.adapters.streamingCard': '流式卡片模式',
'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好',
'settings.adapters.serverUrl': '服务器地址',

View File

@ -0,0 +1,238 @@
import { useState, useEffect } from 'react'
import { useAdapterStore } from '../stores/adapterStore'
import { useTranslation } from '../i18n'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
export function AdapterSettings() {
const t = useTranslation()
const { config, isLoading, fetchConfig, updateConfig } = useAdapterStore()
// Server
const [serverUrl, setServerUrl] = useState('')
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// Telegram
const [tgBotToken, setTgBotToken] = useState('')
const [tgAllowedUsers, setTgAllowedUsers] = useState('')
// Feishu
const [fsAppId, setFsAppId] = useState('')
const [fsAppSecret, setFsAppSecret] = useState('')
const [fsEncryptKey, setFsEncryptKey] = useState('')
const [fsVerificationToken, setFsVerificationToken] = useState('')
const [fsAllowedUsers, setFsAllowedUsers] = useState('')
const [fsStreamingCard, setFsStreamingCard] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle')
const [saveError, setSaveError] = useState('')
useEffect(() => {
fetchConfig()
}, [])
// Sync form state when config is loaded
useEffect(() => {
setServerUrl(config.serverUrl ?? '')
setDefaultProjectDir(config.defaultProjectDir ?? '')
setTgBotToken(config.telegram?.botToken ?? '')
setTgAllowedUsers(config.telegram?.allowedUsers?.join(', ') ?? '')
setFsAppId(config.feishu?.appId ?? '')
setFsAppSecret(config.feishu?.appSecret ?? '')
setFsEncryptKey(config.feishu?.encryptKey ?? '')
setFsVerificationToken(config.feishu?.verificationToken ?? '')
setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '')
setFsStreamingCard(config.feishu?.streamingCard ?? false)
}, [config])
async function handleSave() {
setIsSaving(true)
setSaveStatus('idle')
setSaveError('')
try {
const patch: Record<string, unknown> = {}
if (serverUrl) patch.serverUrl = serverUrl
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
const tgUsers = tgAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n))
patch.telegram = {
botToken: tgBotToken || undefined,
allowedUsers: tgUsers.length ? tgUsers : [],
}
const fsUsers = fsAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.feishu = {
appId: fsAppId || undefined,
appSecret: fsAppSecret || undefined,
encryptKey: fsEncryptKey || undefined,
verificationToken: fsVerificationToken || undefined,
allowedUsers: fsUsers.length ? fsUsers : [],
streamingCard: fsStreamingCard,
}
await updateConfig(patch)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
} catch (err) {
setSaveStatus('error')
setSaveError(err instanceof Error ? err.message : 'Save failed')
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[20px] mr-2">progress_activity</span>
Loading...
</div>
)
}
return (
<div className="max-w-2xl space-y-8">
{/* Description */}
<div>
<p className="text-sm text-[var(--color-text-secondary)]">{t('settings.adapters.description')}</p>
</div>
{/* Server URL */}
<Input
label={t('settings.adapters.serverUrl')}
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder={t('settings.adapters.serverUrlPlaceholder')}
/>
{/* Default Project */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
{/* Telegram */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.telegram')}</span>
</div>
<div className="p-4 space-y-4">
<Input
label={t('settings.adapters.botToken')}
type="password"
value={tgBotToken}
onChange={(e) => setTgBotToken(e.target.value)}
placeholder={t('settings.adapters.botTokenPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={tgAllowedUsers}
onChange={(e) => setTgAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
</section>
{/* Feishu */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.feishu')}</span>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
value={fsAppId}
onChange={(e) => setFsAppId(e.target.value)}
placeholder={t('settings.adapters.appIdPlaceholder')}
/>
<Input
label={t('settings.adapters.appSecret')}
type="password"
value={fsAppSecret}
onChange={(e) => setFsAppSecret(e.target.value)}
placeholder={t('settings.adapters.appSecretPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.encryptKey')}
type="password"
value={fsEncryptKey}
onChange={(e) => setFsEncryptKey(e.target.value)}
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
/>
<Input
label={t('settings.adapters.verificationToken')}
type="password"
value={fsVerificationToken}
onChange={(e) => setFsVerificationToken(e.target.value)}
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={fsAllowedUsers}
onChange={(e) => setFsAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={fsStreamingCard}
onChange={(e) => setFsStreamingCard(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
</div>
</label>
</div>
</section>
{/* Save */}
<div className="flex items-center gap-3">
<Button onClick={handleSave} loading={isSaving}>
{saveStatus === 'saved' ? t('settings.adapters.saved') : t('settings.adapters.save')}
</Button>
{saveStatus === 'saved' && (
<span className="text-sm text-[var(--color-success)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">check_circle</span>
{t('settings.adapters.saved')}
</span>
)}
{saveStatus === 'error' && (
<span className="text-sm text-[var(--color-error)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">error</span>
{saveError}
</span>
)}
</div>
</div>
)
}

View File

@ -11,8 +11,9 @@ import type { Locale } from '../i18n'
import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
import { AdapterSettings } from './AdapterSettings'
type SettingsTab = 'providers' | 'permissions' | 'general'
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -38,6 +39,7 @@ export function Settings() {
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
</div>
{/* Tab content */}
@ -45,6 +47,7 @@ export function Settings() {
{activeTab === 'providers' && <ProviderSettings />}
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
</div>
</div>
</div>

View File

@ -0,0 +1,35 @@
import { create } from 'zustand'
import { adaptersApi } from '../api/adapters'
import type { AdapterFileConfig } from '../types/adapter'
type AdapterStore = {
config: AdapterFileConfig
isLoading: boolean
error: string | null
fetchConfig: () => Promise<void>
updateConfig: (patch: Partial<AdapterFileConfig>) => Promise<void>
}
export const useAdapterStore = create<AdapterStore>((set) => ({
config: {},
isLoading: false,
error: null,
fetchConfig: async () => {
set({ isLoading: true, error: null })
try {
const config = await adaptersApi.getConfig()
set({ config, isLoading: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load config'
set({ isLoading: false, error: message })
}
},
updateConfig: async (patch) => {
// PUT returns the merged masked config — no need for a separate GET
const config = await adaptersApi.updateConfig(patch)
set({ config })
},
}))

View File

@ -0,0 +1,18 @@
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string
telegram?: {
botToken?: string
allowedUsers?: number[]
defaultWorkDir?: string
}
feishu?: {
appId?: string
appSecret?: string
encryptKey?: string
verificationToken?: string
allowedUsers?: string[]
defaultWorkDir?: string
streamingCard?: boolean
}
}

View File

@ -0,0 +1,505 @@
# IM Gateway 方案设计 `[已采纳 — 轻量脚本方案]`
> 像 OpenClaw 一样,让 Claude Code Desktop 快速接入任意 IM 平台
>
> **最终方案**:不使用 OpenClaw 框架,也不新增 Gateway 模块。每个 IM 平台一个 200-350 行的独立 Adapter 脚本,直连现有 `/ws/:sessionId` 接口,服务端零改动。详见 [`adapters/README.md`](../../adapters/README.md)。
<p align="center">
<a href="#一背景与动机">背景</a> ·
<a href="#二openclaw-参考分析">OpenClaw</a> ·
<a href="#三方案设计">方案</a> ·
<a href="#四消息协议">协议</a> ·
<a href="#五消息流详解">消息流</a> ·
<a href="#六adapter-实现">Adapter</a> ·
<a href="#七文件清单">文件清单</a> ·
<a href="#八验证方案">验证</a> ·
<a href="#九与-openclaw-对比">对比</a> ·
<a href="#十开放问题">开放问题</a>
</p>
---
## 一、背景与动机
### 现状
Claude Code 源码中已有完整的 **Channel 系统**(详见 [01-channel-system.md](./01-channel-system.md)),支持通过 MCP 协议接入 IM 平台。但该系统被六层访问控制锁死:
1. **编译时门控**`feature('KAIROS')` / `feature('KAIROS_CHANNELS')` 编译标志
2. **运行时门控** — GrowthBook `tengu_harbor`(默认 false
3. **OAuth 门控** — 需要 claude.ai OAuth 登录
4. **组织策略门控** — Team/Enterprise 必须显式启用 `channelsEnabled: true`
5. **会话白名单** — 需要 `--channels` CLI 参数指定
6. **插件市场审批** — 插件必须通过 Anthropic 市场审批
这些限制使得在我们自托管的桌面端 App 中无法直接使用 Channel 功能。
### 目标
参考 [OpenClaw](https://github.com/openclaw/openclaw) 的 IM Gateway 架构,在现有桌面端服务器基础上,以**最小改动量**实现 IM 平台接入,让用户可以从 Telegram、飞书、Slack、Discord 等 IM 直接与 Claude 对话并审批权限请求。
### 核心策略
**不拆解 MCP Channel 的门控**,而是在服务端新增 `/im/` WebSocket 入口,直接复用 `conversationService`CLI 子进程管理),绕过整个 MCP 层。
---
## 二、OpenClaw 参考分析
[OpenClaw](https://github.com/openclaw/openclaw) 是 GitHub 上 351k+ star 的开源 AI 助手项目,其最大特色是 IM 集成。
### 支持的 IM 平台23+
WhatsApp、Telegram、Slack、Discord、Google Chat、Signal、iMessage、IRC、Microsoft Teams、Matrix、飞书Feishu、LINE、Mattermost、Nextcloud Talk、Nostr、Synology Chat、Tlon、Twitch、Zalo、微信WeChat、WebChat 等。
### 架构模式
```
IM 平台 (Telegram / WeChat / Slack / ...)
|
v
┌─────────────────────┐
│ Gateway │
│ (WebSocket 控制面) │
│ ws://127.0.0.1:18789│
└──────────┬──────────┘
|
├── AI Agent (RPC) — 模型调用
├── CLI
├── WebChat UI
└── 移动端 App
```
### 关键设计
- **Gateway 是控制面**:所有 IM 消息通过 Gateway 路由到 AI Agent
- **Adapter 模式**:每个 IM 平台一个独立 Adapter连接到 Gateway
- **多用户隔离**:不同用户/聊天对应不同 Agent 会话
- **安全机制**DM 配对码验证未知发送者
### 中国 IM 生态
社区插件仓库 `openclaw-china` 额外支持飞书、钉钉、QQ、企业微信。
---
## 三、方案设计
### 架构总览
```
Telegram / 飞书 / Slack / Discord / 微信 ...
|
| (各平台 SDK)
v
IM Adapter独立进程 ← 每个平台一个
|
| ws://localhost:3456/im/<adapterId>
v
┌──────────────────────┐
│ IM Gateway │ ← 新增模块 src/server/im/
│ (WebSocket handler) │
└──────────┬───────────┘
|
| 复用 conversationService
v
┌──────────────────────┐
│ CLI 子进程 (Claude) │ ← 已有基础设施,零改动
└──────────────────────┘
```
### 设计决策
#### 为什么不直接解锁 MCP Channel 门控?
MCP Channel 系统设计用于 CLI 交互模式React/Ink 渲染需要修改编译标志、GrowthBook 配置、OAuth 逻辑等 6 层代码。而我们的桌面端服务器有完全独立的架构REST + WS + CLI 子进程),直接在服务端接入更简洁。
#### 为什么 Adapter 独立进程?
- IM SDK 很重telegraf ~50 depswechaty 更多),不应污染服务端
- 可以独立重启/更新,不影响服务器
- 自然隔离,一个 Adapter 崩溃不影响其他
- 与 OpenClaw 架构模式一致
#### 为什么用 WebSocket
双向实时通信是核心需求(流式回复、权限请求/回复),服务器已有 WebSocket 基础设施HTTP 轮询会增加延迟和复杂度。
### 复用清单
以下已有代码可直接复用,**无需修改**
| 函数 / 模块 | 文件位置 | 用途 |
|-------------|---------|------|
| `conversationService.*` | `src/server/services/conversationService.ts` | CLI 子进程管理全套 API |
| `shortRequestId()` | `src/services/mcp/channelPermissions.ts:140` | 生成 IM 友好的 5 字母权限 ID |
| `truncateForPreview()` | `src/services/mcp/channelPermissions.ts:160` | 工具输入截断为手机预览大小 |
| `PERMISSION_REPLY_RE` | `src/services/mcp/channelPermissions.ts:75` | 权限回复格式正则匹配 |
| `translateCliMessage()` | `src/server/ws/handler.ts:277` | CLI 消息翻译逻辑(作为参考) |
| `sessionService` | `src/server/services/sessionService.ts` | 会话文件管理 |
---
## 四、消息协议
### Adapter -> Gateway
```typescript
// 注册
{ type: 'register'; platform: string; adapterId: string; secret?: string }
// IM 消息
{ type: 'im_message'; chatId: string; userId: string; userName?: string; content: string; meta?: Record<string, string> }
// 权限回复
{ type: 'permission_reply'; sessionId: string; requestId: string; allowed: boolean }
// 停止生成
{ type: 'stop'; chatId: string }
// 新建会话(用户 /new 命令)
{ type: 'new_session'; chatId: string }
```
### Gateway -> Adapter
```typescript
// 注册确认
{ type: 'registered'; adapterId: string }
// 文本回复(流式)
{ type: 'text'; chatId: string; content: string; isComplete: boolean }
// 思考过程
{ type: 'thinking'; chatId: string; content: string }
// 工具调用
{ type: 'tool_use'; chatId: string; toolName: string; toolUseId: string; input: any }
// 工具结果
{ type: 'tool_result'; chatId: string; toolUseId: string; content: any; isError: boolean }
// 权限请求
{
type: 'permission_request';
chatId: string;
sessionId: string;
requestId: string;
shortId: string; // 5 字母 ID如 "tbxkq"
toolName: string;
description?: string;
inputPreview: string; // 截断后的工具输入
}
// 状态变更
{ type: 'status'; chatId: string; state: 'thinking' | 'streaming' | 'tool_executing' | 'idle' }
// 错误
{ type: 'error'; chatId: string; message: string; code?: string }
// 完成
{ type: 'complete'; chatId: string; usage: { input_tokens: number; output_tokens: number } }
```
---
## 五、消息流详解
### 正常对话流
```
用户在 Telegram 发送 "检查 main.ts 有没有 bug"
→ Telegram Bot 收到消息
→ Adapter 通过 WebSocket 发送:
{ type: "im_message", chatId: "12345", userId: "alice", content: "检查 main.ts" }
→ Gateway 收到
→ 查找 chatId->sessionId 映射(不存在则创建新 session
→ conversationService.startSession(sessionId, workDir, sdkUrl)
→ conversationService.sendMessage(sessionId, content)
→ CLI 子进程启动,调用 Claude API
→ CLI 输出 stream_event
→ conversationService.onOutput() 回调触发
→ Gateway 翻译为 GatewayMessage
→ WebSocket 发送: { type: "text", chatId: "12345", content: "让我看看...", isComplete: false }
→ Adapter 调用 ctx.reply("让我看看...")
→ 用户在 Telegram 看到回复
```
### 权限审批流
```
CLI 发送 control_request:
{ subtype: "can_use_tool", tool_name: "Bash", input: { command: "npm test" } }
→ Gateway 收到
→ 生成 shortId = shortRequestId(toolUseId) → "tbxkq"
→ WebSocket 发送:
{ type: "permission_request", chatId: "12345", shortId: "tbxkq",
toolName: "Bash", inputPreview: '{"command":"npm test"}' }
→ Adapter 在 Telegram 发送:
"需要权限确认
工具: Bash
内容: npm test
[允许] [拒绝]"
→ 用户点击 [允许]
→ Adapter 发送: { type: "permission_reply", requestId: "tbxkq", allowed: true }
→ Gateway 调用 conversationService.respondToPermission(sessionId, requestId, true)
→ CLI 继续执行
```
---
## 六、Adapter 实现
### 目录结构
```
adapters/
telegram/
index.ts — Telegram Bot Adapter基于 telegraf
package.json — 依赖声明
feishu/
index.ts — 飞书 Adapter基于 @larksuiteoapi/node-sdk
package.json
slack/
index.ts — Slack Adapter基于 Bolt
package.json
discord/
index.ts — Discord Adapter基于 discord.js
package.json
wechat/
index.ts — 微信 Adapter基于 wechaty
package.json
```
### Telegram Adapter 示例
```typescript
// 伪代码 — 核心逻辑
import { Telegraf } from 'telegraf'
import WebSocket from 'ws'
const bot = new Telegraf(process.env.BOT_TOKEN)
const ws = new WebSocket('ws://localhost:3456/im/telegram-001')
// IM -> Gateway
bot.on('text', (ctx) => {
ws.send(JSON.stringify({
type: 'im_message',
chatId: String(ctx.chat.id),
userId: String(ctx.from.id),
userName: ctx.from.first_name,
content: ctx.message.text,
}))
})
// Gateway -> IM
ws.on('message', (data) => {
const msg = JSON.parse(data)
switch (msg.type) {
case 'text':
bot.telegram.sendMessage(msg.chatId, msg.content, { parse_mode: 'Markdown' })
break
case 'permission_request':
bot.telegram.sendMessage(msg.chatId,
`需要权限: ${msg.toolName}\n${msg.inputPreview}`,
{ reply_markup: {
inline_keyboard: [[
{ text: '允许', callback_data: `permit:${msg.requestId}:yes` },
{ text: '拒绝', callback_data: `permit:${msg.requestId}:no` },
]]
}}
)
break
}
})
// 处理按钮回调
bot.on('callback_query', (ctx) => {
const [, requestId, decision] = ctx.callbackQuery.data.split(':')
ws.send(JSON.stringify({
type: 'permission_reply',
requestId,
allowed: decision === 'yes',
}))
})
```
### 飞书 Adapter 要点
- 使用飞书事件订阅HTTP 回调模式)接收消息
- 权限请求使用**交互式卡片**Message Card按钮体验更好
- 支持富文本回复
### 各平台消息限制
| 平台 | 消息长度限制 | 处理方式 |
|------|------------|---------|
| Telegram | 4096 字符 | 自动分段发送 |
| 飞书 | 无硬限制(建议 < 30KB | 长消息可折叠 |
| Slack | 40000 字符 | 分 Block 发送 |
| Discord | 2000 字符 | 分段 + Embed |
| 微信 | 2048 字符 | 分段发送 |
---
## 七、文件清单
### 需要修改的现有文件(约 35 行改动)
| 文件 | 改动量 | 内容 |
|------|--------|------|
| `src/server/index.ts` | ~20 行 | 新增 `/im/` WebSocket 升级路径 |
| `src/server/ws/handler.ts` | ~15 行 | WebSocketData 类型扩展为 `'client' \| 'sdk' \| 'im'`open/message/close 中委托 IM 消息给 gateway |
### 需要新建的文件
| 文件 | 说明 |
|------|------|
| `src/server/im/types.ts` | IM 消息协议类型定义 |
| `src/server/im/gateway.ts` | Gateway 核心 — Adapter 连接管理、chatId->session 路由、消息翻译 |
| `src/server/im/sessionMap.ts` | chatId-sessionId 映射管理、可选持久化 |
| `src/server/im/config.ts` | `~/.claude/im-gateway.json` 配置读取 |
| `adapters/telegram/index.ts` | Telegram Bot Adapter |
| `adapters/telegram/package.json` | 依赖 telegraf + ws |
| `adapters/feishu/index.ts` | 飞书 Adapter |
| `adapters/feishu/package.json` | 依赖 @larksuiteoapi/node-sdk + ws |
### 配置文件
`~/.claude/im-gateway.json`
```json
{
"enabled": true,
"defaultWorkDir": "/path/to/default/project",
"defaultPermissionMode": "default",
"adapters": {
"telegram": {
"secret": "optional-shared-secret",
"allowedUsers": ["123456789"],
"workDir": "/path/to/project"
},
"feishu": {
"secret": "optional-shared-secret",
"allowedUsers": ["ou_xxx"],
"workDir": "/path/to/another/project"
}
}
}
```
---
## 八、验证方案
### 1. WebSocket 连通性测试
```bash
# 启动服务器
bun run server
# 用 wscat 模拟 Adapter
wscat -c ws://localhost:3456/im/test-adapter
> {"type":"register","platform":"test","adapterId":"test-001"}
# 期望收到: {"type":"registered","adapterId":"test-001"}
> {"type":"im_message","chatId":"chat-1","userId":"user-1","content":"hello"}
# 期望收到: 一系列 text/thinking/status/complete 消息
```
### 2. Telegram 端到端测试
1. 创建 Telegram Bot@BotFather
2. 配置 Bot Token 到 Adapter
3. 启动服务器 + Telegram Adapter
4. 在 Telegram 发送 "hello"
5. 验证收到 Claude 回复
6. 发送 "读取 package.json" 触发工具调用
7. 验证权限请求按钮出现
8. 点击允许,验证工具执行和结果返回
### 3. 多会话隔离测试
- 从不同 Telegram 聊天发消息,验证会话独立
- 发送 `/new` 命令,验证新会话创建
- 验证旧会话不受影响
### 4. 异常场景测试
- Adapter 断开重连,验证会话恢复
- CLI 进程崩溃,验证错误消息发送到 IM
- 权限请求超时,验证优雅处理
---
## 九、与 OpenClaw 对比
| 特性 | OpenClaw | 我们的方案 |
|------|----------|-----------|
| 架构 | Gateway + Agent (RPC) | Gateway + CLI 子进程 (SDK WS) |
| IM 平台 | 23+ 内置 | Adapter 模式,按需添加 |
| 权限审批 | 无(直接执行) | 5 字母 ID 审批(复用已有系统) |
| 多用户 | 全局单 Agent | 每 chatId 独立 session |
| AI 模型 | OpenAI / Claude / Gemini 等 | Claude通过现有 API key |
| 工具能力 | 浏览器控制、语音等 | 完整 Claude Code 工具集(文件读写、终端、搜索等) |
| 部署方式 | 单体 + 插件 | 服务端 + 独立 Adapter 进程 |
| 改动量 | 全新项目 | 现有服务端 ~35 行改动 + 3 个新模块 |
### 我们的优势
- **Claude Code 原生工具链**Adapter 连接的不是普通聊天机器人,而是完整的 Claude Code Agent具备文件读写、代码搜索、Git 操作、终端执行等全部能力
- **权限安全**:内置权限审批机制,从 IM 端可以审批敏感操作
- **改动量极小**:核心只改 2 个文件 ~35 行,新增 3 个模块
### OpenClaw 的优势
- **IM 平台覆盖**23+ 平台开箱即用
- **社区生态**5400+ 社区 Skills
- **语音交互**:支持语音唤醒和对话
- **多模型**:支持 OpenAI、Gemini 等多种模型
---
## 十、开放问题
> 以下问题在实现前需要进一步思考和决策。
### 10.1 安全性
- Adapter 连接到 Gateway 时是否需要认证shared secret / API key
- 如何防止未授权的 IM 用户与 Claude 对话allowedUsers 白名单是否足够?)
- 是否需要 IP 白名单或 Tailscale 等网络层隔离?
### 10.2 多用户 / 多项目
- 一个 IM 用户是否能切换不同项目目录?(如 `/project /path/to/repo`
- 是否支持多个用户同时使用同一个 Bot
- 会话上限和资源管理(同时运行多少个 CLI 子进程?)
### 10.3 消息体验
- 长回复如何处理?逐段发送 vs 等待完成后发送?
- 用户快速连发多条消息,是否需要聚合?
- 代码块在 IM 中的渲染质量Telegram Markdown vs 飞书富文本 vs 微信纯文本)
### 10.4 运维
- Adapter 进程管理systemd / pm2 / Docker
- 日志和监控
- 服务器重启后 session 恢复
### 10.5 功能边界
- 是否支持文件/图片上传?(用户在 IM 中发送截图给 Claude
- 是否支持 Claude 返回的图片/文件?(如截图、生成的文件)
- 是否支持 slash commands`/help``/status``/new`
---
## 参考资料
- [OpenClaw GitHub](https://github.com/openclaw/openclaw) — 351k star 开源 AI 助手
- [OpenClaw China](https://github.com/BytePioneer-AI/openclaw-china) — 中国 IM 社区插件
- [Channel 系统架构解析](./01-channel-system.md) — 源码 Channel 系统文档
- [Telegraf](https://github.com/telegraf/telegraf) — Telegram Bot Framework
- [@larksuiteoapi/node-sdk](https://github.com/larksuite/oapi-sdk-nodejs) — 飞书 SDK

View File

@ -0,0 +1,954 @@
# IM Adapter Session 持久化改造
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 IM AdapterTelegram/飞书)创建与 Desktop App 互通的持久化 Session实现聊天记录互通、Session 恢复、项目目录选择。
**Architecture:** Adapter 在连接 WebSocket 之前,先通过 HTTP `POST /api/sessions` 创建正式 UUID Session。chatId→sessionId 映射持久化到本地 JSON 文件adapter 重启后可恢复。用户通过 `/projects` 命令从最近项目列表选择工作目录,或使用 Settings 页面配置的默认目录。
**Tech Stack:** Bun (HTTP fetch + WebSocket), existing server REST API (`/api/sessions`, `/api/sessions/recent-projects`), JSON file persistence
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `adapters/common/session-store.ts` | Create | chatId→sessionId 持久化映射 |
| `adapters/common/http-client.ts` | Create | 调用服务端 REST API创建 session、列出项目 |
| `adapters/common/ws-bridge.ts` | Modify | 移除自动 sessionId 生成,接受外部传入的 sessionId |
| `adapters/telegram/index.ts` | Modify | 接入 session 管理 + `/projects` 命令 |
| `adapters/feishu/index.ts` | Modify | 同 Telegram 的改造 |
| `adapters/common/config.ts` | Modify | 新增 `defaultProjectDir` 顶层配置字段 |
| `src/server/services/adapterService.ts` | Modify | AdapterFileConfig 新增 `defaultProjectDir` |
| `desktop/src/types/adapter.ts` | Modify | 前端类型同步 |
| `desktop/src/pages/AdapterSettings.tsx` | Modify | 新增"默认项目"字段 |
| `desktop/src/i18n/locales/en.ts` | Modify | i18n |
| `desktop/src/i18n/locales/zh.ts` | Modify | i18n |
| `adapters/common/__tests__/session-store.test.ts` | Create | session-store 测试 |
| `adapters/common/__tests__/http-client.test.ts` | Create | http-client 测试 |
---
### Task 1: Session Store — 持久化 chatId→sessionId 映射
**Files:**
- Create: `adapters/common/session-store.ts`
- Create: `adapters/common/__tests__/session-store.test.ts`
- [ ] **Step 1: Write the failing tests**
```typescript
// adapters/common/__tests__/session-store.test.ts
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')
// Create a new store instance pointing to the same file
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)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd adapters && bun test common/__tests__/session-store.test.ts`
Expected: FAIL — module `../session-store.js` not found
- [ ] **Step 3: Implement SessionStore**
```typescript
// adapters/common/session-store.ts
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)
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd adapters && bun test common/__tests__/session-store.test.ts`
Expected: 7 pass, 0 fail
- [ ] **Step 5: Commit**
```bash
git add adapters/common/session-store.ts adapters/common/__tests__/session-store.test.ts
git commit -m "feat(adapters): add persistent chatId→sessionId store"
```
---
### Task 2: HTTP Client — 调用服务端 REST API
**Files:**
- Create: `adapters/common/http-client.ts`
- Create: `adapters/common/__tests__/http-client.test.ts`
- [ ] **Step 1: Write the failing tests**
```typescript
// adapters/common/__tests__/http-client.test.ts
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()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd adapters && bun test common/__tests__/http-client.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement AdapterHttpClient**
```typescript
// adapters/common/http-client.ts
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export class AdapterHttpClient {
readonly httpBaseUrl: string
constructor(wsUrl: string) {
this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/$/, '')
}
async createSession(workDir: string): Promise<string> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
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
}
async listRecentProjects(): Promise<RecentProject[]> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
if (!res.ok) {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd adapters && bun test common/__tests__/http-client.test.ts`
Expected: 4 pass, 0 fail
- [ ] **Step 5: Commit**
```bash
git add adapters/common/http-client.ts adapters/common/__tests__/http-client.test.ts
git commit -m "feat(adapters): add HTTP client for server session API"
```
---
### Task 3: WsBridge — 支持外部传入 sessionId
**Files:**
- Modify: `adapters/common/ws-bridge.ts:46-56`
- Modify: `adapters/common/__tests__/ws-bridge.test.ts`
- [ ] **Step 1: Update ws-bridge.test.ts — add test for connectSession**
在现有测试文件末尾,`describe('WsBridge')` 块内添加:
```typescript
it('connectSession connects with provided sessionId', () => {
bridge.connectSession('chat-1', 'my-uuid-session-id')
expect(bridge.hasSession('chat-1')).toBe(true)
})
it('connectSession reuses existing open connection', () => {
bridge.connectSession('chat-1', 'uuid-1')
// Simulate WS open
const ws1 = (bridge as any).sessions.get('chat-1')?.ws
bridge.connectSession('chat-1', 'uuid-2')
// Should not replace if first is still connecting/open
// (In practice, WS is in CONNECTING state, so it gets replaced)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd adapters && bun test common/__tests__/ws-bridge.test.ts`
Expected: FAIL — `bridge.connectSession is not a function`
- [ ] **Step 3: Modify ws-bridge.ts**
Replace `getOrCreateSession` with `connectSession`:
```typescript
// In ws-bridge.ts, replace the getOrCreateSession method (lines 46-56) with:
/** 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 // already connected
}
this.connect(chatId, sessionId)
return true
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd adapters && bun test common/__tests__/ws-bridge.test.ts`
Expected: All pass (existing tests updated if they reference `getOrCreateSession`)
Note: If existing tests call `getOrCreateSession`, update them to use `connectSession` with an explicit sessionId like `'test-session-id'`.
- [ ] **Step 5: Commit**
```bash
git add adapters/common/ws-bridge.ts adapters/common/__tests__/ws-bridge.test.ts
git commit -m "refactor(ws-bridge): replace getOrCreateSession with connectSession"
```
---
### Task 4: Config — 新增 defaultProjectDir
**Files:**
- Modify: `adapters/common/config.ts:27-31`
- Modify: `src/server/services/adapterService.ts:13-29`
- Modify: `desktop/src/types/adapter.ts`
- Modify: `desktop/src/pages/AdapterSettings.tsx`
- Modify: `desktop/src/i18n/locales/en.ts`
- Modify: `desktop/src/i18n/locales/zh.ts`
- [ ] **Step 1: Add defaultProjectDir to adapter config types**
In `adapters/common/config.ts`, add to `AdapterConfig` type and `loadConfig`:
```typescript
// adapters/common/config.ts — update AdapterConfig type (line 27-31)
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string // ← NEW
telegram: TelegramConfig
feishu: FeishuConfig
}
// In loadConfig() return statement, add:
return {
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
defaultProjectDir: file.defaultProjectDir || '', // ← NEW
telegram: { ... },
feishu: { ... },
}
```
- [ ] **Step 2: Add to server-side AdapterFileConfig**
In `src/server/services/adapterService.ts`, add `defaultProjectDir` to the type (line 14):
```typescript
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string // ← NEW
telegram?: { ... }
feishu?: { ... }
}
```
- [ ] **Step 3: Add to frontend type**
In `desktop/src/types/adapter.ts`, add:
```typescript
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string // ← NEW
telegram?: { ... }
feishu?: { ... }
}
```
- [ ] **Step 4: Add UI field in AdapterSettings.tsx**
Add a `defaultProjectDir` state + `DirectoryPicker` between the server URL and the Telegram section:
```tsx
// Add import at top
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
// Add state
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// In useEffect config sync, add:
setDefaultProjectDir(config.defaultProjectDir ?? '')
// In handleSave, add to patch:
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
// In JSX, after the Server URL Input and before the Telegram section:
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
```
- [ ] **Step 5: Add i18n keys**
In `desktop/src/i18n/locales/en.ts`, in the adapters section:
```typescript
'settings.adapters.defaultProject': 'Default Project',
'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.',
```
In `desktop/src/i18n/locales/zh.ts`:
```typescript
'settings.adapters.defaultProject': '默认项目',
'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。',
```
- [ ] **Step 6: Update API validation whitelist**
In `src/server/api/adapters.ts`, add `'defaultProjectDir'` to `ALLOWED_TOP_KEYS`:
```typescript
const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu'])
```
- [ ] **Step 7: Run TypeScript check**
Run: `cd desktop && npx tsc --noEmit`
Expected: No errors
- [ ] **Step 8: Commit**
```bash
git add adapters/common/config.ts src/server/services/adapterService.ts src/server/api/adapters.ts \
desktop/src/types/adapter.ts desktop/src/pages/AdapterSettings.tsx \
desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts
git commit -m "feat: add defaultProjectDir to adapter config and settings UI"
```
---
### Task 5: Telegram Adapter — Session 管理 + /projects 命令
**Files:**
- Modify: `adapters/telegram/index.ts`
- [ ] **Step 1: Add imports and initialization**
At the top of `adapters/telegram/index.ts`, add imports and initialize new modules:
```typescript
// Add these imports after existing ones
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
// After existing init section (after dedup initialization)
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
// Track chats waiting for project selection
const pendingProjectSelection = new Map<string, boolean>()
```
- [ ] **Step 2: Replace setupMessageHandler with session-aware version**
Replace the current `setupMessageHandler` function with:
```typescript
async function ensureSession(chatId: string): Promise<boolean> {
// Already connected?
if (bridge.hasSession(chatId)) return true
// Has stored session? Reconnect.
const stored = sessionStore.get(chatId)
if (stored) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
}
// Need to create a new session — use default project or ask
const workDir = config.defaultProjectDir
if (workDir) {
return await createSessionForChat(chatId, workDir)
}
// No default — ask user to pick
await showProjectPicker(chatId)
return false // message not sent yet, waiting for project selection
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
const numericChatId = Number(chatId)
try {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
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')}`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
```
- [ ] **Step 3: Extract handleServerMessage from inline closure**
Extract the server message handler (currently the inline `async (msg: ServerMessage) => { ... }` in the old `setupMessageHandler`) into a standalone function. The body is the same `switch(msg.type)` block, just named:
```typescript
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const numericChatId = Number(chatId)
const buf = getBuffer(chatId)
switch (msg.type) {
// ... exact same cases as before (status, content_start, content_delta,
// thinking, tool_use_complete, tool_result, permission_request,
// message_complete, error)
// No changes to the switch body.
}
}
```
- [ ] **Step 4: Update /new command**
Replace the `/new` command handler:
```typescript
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
// Clean up current session state
bridge.resetSession(chatId)
sessionStore.delete(chatId)
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
// Show project picker for next session
await showProjectPicker(chatId)
})
```
- [ ] **Step 5: Add /projects command**
```typescript
bot.command('projects', async (ctx) => {
const chatId = String(ctx.chat.id)
await showProjectPicker(chatId)
})
```
- [ ] **Step 6: Update message handler for project selection + normal messages**
Replace the `bot.on('message:text')` handler:
```typescript
bot.on('message:text', (ctx) => {
if (!ctx.from || !isAllowed(ctx.from.id)) return
if (!dedup.tryRecord(String(ctx.message.message_id))) return
const chatId = String(ctx.chat.id)
const text = ctx.message.text
enqueue(chatId, async () => {
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await bot.api.sendMessage(Number(chatId),
`✅ 已选择 ${selected.projectName}。现在可以开始对话了。`)
return
}
} catch { /* fall through to normal handling */ }
}
// Invalid selection — tell user
await bot.api.sendMessage(Number(chatId), '请输入有效的编号。')
return
}
// Normal message flow
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text)
}
// If not ready, ensureSession already showed the project picker
})
})
```
- [ ] **Step 7: Update /start command help text**
```typescript
bot.command('start', (ctx) => {
ctx.reply(
'👋 Claude Code Bot 已就绪。\n\n' +
'命令:\n' +
'/projects — 选择/切换项目\n' +
'/new — 新建会话\n' +
'/stop — 停止生成'
)
})
```
- [ ] **Step 8: Update SIGINT handler — add sessionStore**
No change needed, sessionStore is sync-write so data is already persisted.
- [ ] **Step 9: Run all adapter tests**
Run: `cd adapters && bun test`
Expected: All pass (telegram mock tests may need updating if they reference `getOrCreateSession` — update mocks to use `connectSession`)
- [ ] **Step 10: Commit**
```bash
git add adapters/telegram/index.ts
git commit -m "feat(telegram): session persistence, /projects command, project selection"
```
---
### Task 6: Feishu Adapter — Session 管理 + /projects 命令
**Files:**
- Modify: `adapters/feishu/index.ts`
- [ ] **Step 1: Add imports and initialization**
Same pattern as Telegram — add at the top:
```typescript
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
// After existing init
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const pendingProjectSelection = new Map<string, boolean>()
```
- [ ] **Step 2: Add ensureSession, createSessionForChat, showProjectPicker**
Same functions as Telegram, but using `sendText(chatId, text)` instead of `bot.api.sendMessage(...)`:
```typescript
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 true
}
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> {
try {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
} catch (err) {
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await sendText(chatId,
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。')
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 sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`)
} catch (err) {
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
```
- [ ] **Step 3: Extract handleServerMessage**
Same as Telegram — extract the `switch(msg.type)` body from the inline closure in old `setupMessageHandler` into a standalone `handleServerMessage(chatId, msg)` function.
- [ ] **Step 4: Update command handling in handleMessage**
In the `handleMessage` function, update the command section (around line 364):
```typescript
// Handle commands
if (text === '/new' || text === '新会话') {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
chatStates.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
await showProjectPicker(chatId)
return
}
if (text === '/stop' || text === '停止') {
bridge.sendStopGeneration(chatId)
await sendText(chatId, '⏹ 已发送停止信号。')
return
}
if (text === '/projects' || text === '项目列表') {
await showProjectPicker(chatId)
return
}
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await sendText(chatId, '请输入有效的编号。')
return
}
// Normal message flow
enqueue(chatId, async () => {
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text!)
}
})
```
- [ ] **Step 5: Run all adapter tests**
Run: `cd adapters && bun test`
Expected: All pass
- [ ] **Step 6: Commit**
```bash
git add adapters/feishu/index.ts
git commit -m "feat(feishu): session persistence, /projects command, project selection"
```
---
### Task 7: Update existing tests for API changes
**Files:**
- Modify: `adapters/telegram/__tests__/telegram.test.ts`
- Modify: `adapters/feishu/__tests__/feishu.test.ts`
- Modify: `adapters/common/__tests__/ws-bridge.test.ts`
- [ ] **Step 1: Update ws-bridge tests**
Replace any `getOrCreateSession` calls with `connectSession`:
```typescript
// Find: bridge.getOrCreateSession('chat-1')
// Replace: bridge.connectSession('chat-1', 'test-session-id')
```
- [ ] **Step 2: Update telegram tests**
Update the mocked bridge to use `connectSession` instead of `getOrCreateSession`. Update any mock setup that references the old API.
- [ ] **Step 3: Update feishu tests**
Same as telegram test updates.
- [ ] **Step 4: Run all tests**
Run: `cd adapters && bun test`
Expected: All pass, 0 fail
- [ ] **Step 5: Commit**
```bash
git add adapters/telegram/__tests__/telegram.test.ts adapters/feishu/__tests__/feishu.test.ts \
adapters/common/__tests__/ws-bridge.test.ts
git commit -m "test: update adapter tests for connectSession API"
```
---
### Task 8: Final TypeScript check + verify Desktop build
**Files:** None new — verification only.
- [ ] **Step 1: TypeScript check for desktop**
Run: `cd desktop && npx tsc --noEmit`
Expected: No errors
- [ ] **Step 2: Run all adapter tests**
Run: `cd adapters && bun test`
Expected: All pass
- [ ] **Step 3: Verify adapters.json is properly written**
Run: `cat ~/.claude/adapters.json` (if exists)
Verify `defaultProjectDir` field can be read.
- [ ] **Step 4: Final commit (if any fixups needed)**
```bash
git add -A
git commit -m "chore: fixup after session persistence integration"
```

77
fixtures/e8c7b0.json Normal file
View File

@ -0,0 +1,77 @@
{
"input": [
[
{
"type": "text",
"text": "<system-reminder>\nPlan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.\n\n## Plan File Info:\nNo plan file exists yet. You should create your plan at [CONFIG_HOME]/plans/elegant-weaving-map.md using the Write tool.\nYou should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.\n\n## Plan Workflow\n\n### Phase 1: Initial Understanding\nGoal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the Explore subagent type.\n\n1. Focus on understanding the user's request and the code associated with their request. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.\n\n2. **Launch up to 3 Explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.\n - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.\n - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.\n - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)\n - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigating testing patterns\n\n### Phase 2: Design\nGoal: Design an implementation approach.\n\nLaunch Plan agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.\n\nYou can launch up to 1 agent(s) in parallel.\n\n**Guidelines:**\n- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives\n- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)\n\nIn the agent prompt:\n- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces\n- Describe requirements and constraints\n- Request a detailed implementation plan\n\n### Phase 3: Review\nGoal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.\n1. Read the critical files identified by agents to deepen your understanding\n2. Ensure that the plans align with the user's original request\n3. Use AskUserQuestion to clarify any remaining questions with the user\n\n### Phase 4: Final Plan\nGoal: Write your final plan to the plan file (the only file you can edit).\n- Begin with a **Context** section: explain why this change is being made — the problem or need it addresses, what prompted it, and the intended outcome\n- Include only your recommended approach, not all alternatives\n- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively\n- Include the paths of critical files to be modified\n- Reference existing functions and utilities you found that should be reused, with their file paths\n- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)\n\n### Phase 5: Call ExitPlanMode\nAt the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call ExitPlanMode to indicate to the user that you are done planning.\nThis is critical - your turn should only end with either using the AskUserQuestion tool OR calling ExitPlanMode. Do not stop unless it's for these 2 reasons\n\n**Important:** Use AskUserQuestion ONLY to clarify requirements or choose between approaches. Use ExitPlanMode to request plan approval. Do NOT ask about plan approval in any other way - no text questions, no AskUserQuestion. Phrases like \"Is this plan okay?\", \"Should I proceed?\", \"How does this plan look?\", \"Any changes before we start?\", or similar MUST use ExitPlanMode.\n\nNOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications using the AskUserQuestion tool. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.\n</system-reminder>\n"
},
{
"type": "text",
"text": "Hello"
}
]
],
"output": [
{
"type": "stream_event",
"event": {
"type": "message_start",
"message": {
"id": "",
"type": "message",
"role": "assistant",
"content": [],
"stop_reason": null,
"stop_sequence": null,
"usage": {
"input_tokens": 0,
"output_tokens": 0
},
"service_tier": "standard"
}
},
"ttftMs": 716
},
{
"type": "stream_event",
"event": {
"type": "message_stop"
}
},
{
"uuid": "UUID-2",
"requestId": "REQUEST_ID",
"timestamp": "2026-04-08T09:37:43.983Z",
"message": {
"id": "062554e2bc7e76b93ee40ad7127ce5e8",
"type": "message",
"role": "assistant",
"model": "MiniMax-M2.7",
"content": [
{
"thinking": "The user has sent a simple greeting \"Hello\". Since plan mode is active and I need to follow the plan workflow, but this is just a greeting, I should respond in a friendly way and see what they need help with. This is not an implementation task yet, so I don't need to create a plan file or launch agents.\n\nLet me just greet them and ask what they need help with.",
"signature": "298588bc9e80b3bb0b7d761ee217342ecb2d7db8f2eff3be84d9a827a81fdaf1",
"type": "thinking"
},
{
"text": "Hello! I'm ready to help you with software engineering tasks. What would you like to work on?",
"type": "text",
"citations": []
}
],
"usage": {
"input_tokens": 152,
"output_tokens": 104,
"cache_creation_input_tokens": 24050,
"cache_read_input_tokens": 0
},
"stop_reason": "end_turn",
"base_resp": {
"status_code": 0,
"status_msg": ""
}
},
"type": "assistant"
}
]
}

View File

@ -0,0 +1,41 @@
/**
* Adapters API IM Adapter
*
* GET /api/adapters
* PUT /api/adapters
*/
import { adapterService } from '../services/adapterService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu'])
export async function handleAdaptersApi(
req: Request,
_url: URL,
_segments: string[],
): Promise<Response> {
try {
if (req.method === 'GET') {
const config = await adapterService.getConfig()
return Response.json(config)
}
if (req.method === 'PUT') {
const body = (await req.json()) as Record<string, unknown>
// Basic validation: only allow known top-level keys
for (const key of Object.keys(body)) {
if (!ALLOWED_TOP_KEYS.has(key)) {
throw ApiError.badRequest(`Unknown config key: ${key}`)
}
}
await adapterService.updateConfig(body)
const config = await adapterService.getConfig()
return Response.json(config)
}
throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
} catch (error) {
return errorResponse(error)
}
}

View File

@ -13,6 +13,7 @@ import { handleConversationsApi } from './api/conversations.js'
import { handleTeamsApi } from './api/teams.js'
import { handleFilesystemRoute } from './api/filesystem.js'
import { handleProvidersApi } from './api/providers.js'
import { handleAdaptersApi } from './api/adapters.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -63,6 +64,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'providers':
return handleProvidersApi(req, url, segments)
case 'adapters':
return handleAdaptersApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -0,0 +1,115 @@
/**
* Adapter Service IM Adapter
*
* ~/.claude/adapters.json
* rename
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string
telegram?: {
botToken?: string
allowedUsers?: number[]
defaultWorkDir?: string
}
feishu?: {
appId?: string
appSecret?: string
encryptKey?: string
verificationToken?: string
allowedUsers?: string[]
defaultWorkDir?: string
streamingCard?: boolean
}
}
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapters.json')
}
function maskSecret(value: string | undefined): string | undefined {
if (!value) return value
if (value.length <= 4) return '****'
return '****' + value.slice(-4)
}
function isMasked(value: string | undefined): boolean {
return !!value && value.startsWith('****')
}
class AdapterService {
/** 读取原始配置(不脱敏) */
async getRawConfig(): Promise<AdapterFileConfig> {
try {
const raw = await fs.readFile(getConfigPath(), 'utf-8')
return JSON.parse(raw) as AdapterFileConfig
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return {}
}
throw ApiError.internal(`Failed to read adapter config: ${err}`)
}
}
/** 读取配置(敏感字段脱敏) */
async getConfig(): Promise<AdapterFileConfig> {
const config = await this.getRawConfig()
if (config.telegram?.botToken) {
config.telegram.botToken = maskSecret(config.telegram.botToken)
}
if (config.feishu) {
if (config.feishu.appSecret) config.feishu.appSecret = maskSecret(config.feishu.appSecret)
if (config.feishu.encryptKey) config.feishu.encryptKey = maskSecret(config.feishu.encryptKey)
if (config.feishu.verificationToken) config.feishu.verificationToken = maskSecret(config.feishu.verificationToken)
}
return config
}
/** 更新配置(浅合并,敏感字段如果是脱敏值则保留原值) */
async updateConfig(patch: Partial<AdapterFileConfig>): Promise<void> {
const current = await this.getRawConfig()
// 保留已存储的密钥(如果前端传回的是脱敏值)
if (patch.telegram && isMasked(patch.telegram.botToken)) {
patch.telegram.botToken = current.telegram?.botToken
}
if (patch.feishu) {
if (isMasked(patch.feishu.appSecret)) patch.feishu.appSecret = current.feishu?.appSecret
if (isMasked(patch.feishu.encryptKey)) patch.feishu.encryptKey = current.feishu?.encryptKey
if (isMasked(patch.feishu.verificationToken)) patch.feishu.verificationToken = current.feishu?.verificationToken
}
const merged: AdapterFileConfig = {
...current,
...patch,
telegram: patch.telegram ? { ...current.telegram, ...patch.telegram } : current.telegram,
feishu: patch.feishu ? { ...current.feishu, ...patch.feishu } : current.feishu,
}
await this.writeConfig(merged)
}
private async writeConfig(data: AdapterFileConfig): Promise<void> {
const filePath = getConfigPath()
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
const tmpFile = `${filePath}.tmp.${Date.now()}`
try {
await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf-8')
await fs.rename(tmpFile, filePath)
} catch (err) {
await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write adapter config: ${err}`)
}
}
}
export const adapterService = new AdapterService()