mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
983d83f86b
commit
82e6e27687
258
adapters/README.md
Normal file
258
adapters/README.md
Normal 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
141
adapters/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
61
adapters/common/__tests__/chat-queue.test.ts
Normal file
61
adapters/common/__tests__/chat-queue.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
100
adapters/common/__tests__/format.test.ts
Normal file
100
adapters/common/__tests__/format.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
66
adapters/common/__tests__/http-client.test.ts
Normal file
66
adapters/common/__tests__/http-client.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
84
adapters/common/__tests__/message-buffer.test.ts
Normal file
84
adapters/common/__tests__/message-buffer.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
57
adapters/common/__tests__/message-dedup.test.ts
Normal file
57
adapters/common/__tests__/message-dedup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
adapters/common/__tests__/session-store.test.ts
Normal file
62
adapters/common/__tests__/session-store.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
56
adapters/common/__tests__/ws-bridge.test.ts
Normal file
56
adapters/common/__tests__/ws-bridge.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
24
adapters/common/chat-queue.ts
Normal file
24
adapters/common/chat-queue.ts
Normal 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
73
adapters/common/config.ts
Normal 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
59
adapters/common/format.ts
Normal 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')
|
||||
}
|
||||
44
adapters/common/http-client.ts
Normal file
44
adapters/common/http-client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
91
adapters/common/message-buffer.ts
Normal file
91
adapters/common/message-buffer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
adapters/common/message-dedup.ts
Normal file
57
adapters/common/message-dedup.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
60
adapters/common/session-store.ts
Normal file
60
adapters/common/session-store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
196
adapters/common/ws-bridge.ts
Normal file
196
adapters/common/ws-bridge.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
234
adapters/feishu/__tests__/feishu.test.ts
Normal file
234
adapters/feishu/__tests__/feishu.test.ts
Normal 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
551
adapters/feishu/index.ts
Normal 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
23
adapters/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
115
adapters/telegram/__tests__/telegram.test.ts
Normal file
115
adapters/telegram/__tests__/telegram.test.ts
Normal 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
342
adapters/telegram/index.ts
Normal 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
17
adapters/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
desktop/src/api/adapters.ts
Normal file
12
desktop/src/api/adapters.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '服务器地址',
|
||||
|
||||
238
desktop/src/pages/AdapterSettings.tsx
Normal file
238
desktop/src/pages/AdapterSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
35
desktop/src/stores/adapterStore.ts
Normal file
35
desktop/src/stores/adapterStore.ts
Normal 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 })
|
||||
},
|
||||
}))
|
||||
18
desktop/src/types/adapter.ts
Normal file
18
desktop/src/types/adapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
505
docs/channel/02-im-gateway-proposal.md
Normal file
505
docs/channel/02-im-gateway-proposal.md
Normal 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 deps,wechaty 更多),不应污染服务端
|
||||
- 可以独立重启/更新,不影响服务器
|
||||
- 自然隔离,一个 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
|
||||
954
docs/superpowers/plans/2026-04-08-im-session-persistence.md
Normal file
954
docs/superpowers/plans/2026-04-08-im-session-persistence.md
Normal 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 Adapter(Telegram/飞书)创建与 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
77
fixtures/e8c7b0.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
src/server/api/adapters.ts
Normal file
41
src/server/api/adapters.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
115
src/server/services/adapterService.ts
Normal file
115
src/server/services/adapterService.ts
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user