# OpenClaw Channel 插件开发指南 > 基于钉钉 Channel 插件(`src/channels/dingtalk.ts`)的完整开发经验总结。 > 开发新 Channel 时,本文档是你的第一参考。 --- ## 一、整体架构 每个 OpenClaw 实例是一个独立 Docker 容器(`hailin168/openclaw-bridge:latest`)。 容器内有三个进程,由 supervisord 管理: ``` 容器 ├── openclaw gateway 端口 18789 (内部) — 核心 AI Agent 进程 ├── it0-bridge 端口 3000 (对外) — IT0 管理 REST API └── dingtalk-channel 无对外端口 — 第三方 Channel 进程(可选) ``` **Channel 插件的职责:** 1. 连接第三方平台(接收用户消息) 2. 转发给 OpenClaw Gateway(通过 WebSocket RPC) 3. 将 OpenClaw 响应回发给用户 Channel 插件不需要任何对外端口,完全依赖出向连接。 --- ## 二、OpenClaw Gateway 协议 (Protocol v3) > ⚠️ 这是最重要的部分。协议细节必须准确,否则运行时全部失败,TypeScript 编译器无法检测。 ### 2.1 Wire 帧格式 ```typescript // 请求帧(Client → Gateway) interface ReqFrame { type: 'req'; // 必须是字面量 'req' id: string; // 唯一请求 ID(自增字符串即可) method: string; // RPC 方法名 params?: unknown; // 方法参数 idempotencyKey?: string; // 可选,幂等键 } // 响应帧(Gateway → Client) interface ResFrame { type: 'res'; id: string; // 对应请求的 id ok: boolean; // 成功/失败标志 payload?: unknown; // 成功时的响应数据 error?: { code: string; message: string; retryable?: boolean; retryAfterMs?: number; }; } // 事件帧(Gateway → Client,主动推送) interface EventFrame { type: 'event'; event: string; // 事件名 payload?: unknown; seq?: number; // 单调递增序号 } ``` ### 2.2 连接握手(必须完成,否则所有 RPC 都会被拒绝) OpenClaw 使用 **Challenge-Response + Ed25519 签名** 认证。流程: ``` Client ──────── WebSocket Connect ──────────────────→ Gateway Gateway ──── event: "connect.challenge" { nonce } ──→ Client Client ──── req: "connect" { auth.token, device } ──→ Gateway Gateway ─── res: "connect" { ok: true } ────────────→ Client ↑ 这之后才能发送任何 RPC 请求 ``` 关键实现细节: ```typescript // 1. 用 Node.js 内置 crypto 生成临时 Ed25519 密钥对(每次连接生成新的) const keyPair = crypto.generateKeyPairSync('ed25519'); // 2. 用私钥对 nonce (hex string → Buffer) 签名 const nonceBuffer = Buffer.from(nonce, 'hex'); const signature = crypto.sign(null, nonceBuffer, keyPair.privateKey); // 3. 公钥导出为 DER 格式 base64 const pubKeyDer = keyPair.publicKey.export({ type: 'spki', format: 'der' }); // 4. 发送 connect 请求 { type: 'req', id: '__connect__', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: deviceId, version: '1.0.0', platform: 'node', mode: 'channel' }, role: 'operator', scopes: ['operator.read', 'operator.write'], auth: { token: OPENCLAW_GATEWAY_TOKEN }, // 环境变量注入的 token device: { id: deviceId, publicKey: pubKeyDer.toString('base64'), signature: signature.toString('base64'), signedAt: Date.now(), } } } ``` 握手超时:Gateway 有 10s 超时,客户端应设 12s 安全超时。 ### 2.3 关键 RPC 方法(经官方源码验证) | 功能 | 方法名 | 关键参数 | |------|--------|---------| | 发消息给 Agent | `chat.send` | `{ sessionKey, message, timeoutSeconds }` | | 列出会话 | `sessions.list` | `{ limit, kinds?, activeMinutes? }` | | 获取会话历史 | `sessions.history` | `{ sessionKey, messageLimit }` | | 中止当前任务 | `chat.abort` | `{ sessionKey }` | | 网关状态 | `gateway.status` | 无 | | 健康检查 | `gateway.health` | 无 | > ❌ **以下方法不存在,不要使用:** > - `agent.run` — 根本不存在 > - `metrics.get` — 不存在,用 `gateway.status` 替代 ### 2.4 Session Key 格式 格式:`"type:agentName:sessionName"` ``` agent:main:main — 默认主 session agent:main:dt-{userId} — 钉钉用户专属 session(每用户独立) agent:main:tg-{chatId} — Telegram chat 专属 session agent:main:wechat-{openId} — 微信用户专属 session ``` 每个 OpenClaw 实例默认只有一个 agent "main"。 Session name 部分可以自定义,Gateway 会自动创建不存在的 session。 ### 2.5 `chat.send` 的响应结构 ```typescript // payload 结构 { runId: string; status: 'started' | 'in_flight' | 'ok' | 'timeout'; reply?: string; // 当 timeoutSeconds > 0 且 status=ok 时有值 error?: string; // 当 status=timeout 时有值 } // 从 payload 提取回复文本的正确方式: const payload = result as { status: string; reply?: string }; const replyText = payload.reply ?? '(任务已提交,正在处理)'; ``` --- ## 三、钉钉 Channel 实现要点 ### 3.1 使用 Stream 模式(无需公网 IP) 钉钉官方提供两种接入方式: - **HTTP 回调模式**:需要公网 IP + 备案,不适合容器部署 - **Stream 模式**:主动连接钉钉服务器,容器内部署完美适配 ✓ Stream 模式连接流程: ``` 1. POST https://api.dingtalk.com/v1.0/oauth2/accessToken → { accessToken, expireIn: 7200 } 2. POST https://api.dingtalk.com/v1.0/gateway/connections/open Headers: x-acs-dingtalk-access-token: {accessToken} Body: { clientId, clientSecret, subscriptions: [{type:'CALLBACK', topic:'/v1.0/im/bot/messages/get'}] } → { endpoint, ticket } 3. WSS connect: {endpoint}?ticket={ticket} → 保持长连接,接收消息 ``` ### 3.2 消息帧格式 ```typescript // 服务器推送的消息帧 { type: 'CALLBACK', // or 'PING', 'EVENT', 'SYSTEM' headers: { topic: '/v1.0/im/bot/messages/get', ... }, data: string // JSON string } // data 解析后的 bot 消息结构 { senderStaffId: string, // 发送者工号 sessionWebhook: string, // 用于回复的 Webhook URL(有过期时间!) sessionWebhookExpiredTime: number, // 毫秒时间戳 text: { content: string }, // 消息文本 conversationId: string, // 群/单聊会话 ID msgId: string // 消息唯一 ID(用于去重) } // 必须在收到消息后 1.5 秒内 ACK,否则钉钉会重发 ws.send(JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' })); ``` ### 3.3 已验证的钉钉 API 限制 | 限制 | 数值 | |------|------| | Access Token 有效期 | 7200 秒(2小时)| | 单条消息字符上限 | 5000 字符 | | ACK 超时 | 1.5 秒 | | sessionWebhook 有效期 | 约 24 小时(群消息更短)| ### 3.4 回复钉钉消息(通过 sessionWebhook) ```typescript function sendWebhook(webhook: string, content: string): void { const url = new URL(webhook); const body = JSON.stringify({ msgtype: 'text', text: { content } }); // 用 Node.js 内置 https 模块,无外部依赖 const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST', ... }); } ``` 超过 4800 字符需分块发送,每块间隔 200ms 保证顺序。 --- ## 四、生产级鲁棒性模式(从错误中总结) ### 4.1 Token 自动刷新 + 防竞态 ```typescript class TokenManager { private refreshPromise: Promise | null = null; // 互斥锁 async get(): Promise { if (Date.now() < this.expiresAt - BUFFER_MS) return this.token; // 关键:多个并发调用只触发一次刷新 if (this.refreshPromise) return this.refreshPromise; this.refreshPromise = this.doRefresh().finally(() => { this.refreshPromise = null; }); return this.refreshPromise; } } ``` ### 4.2 消息去重(O(1) + TTL) ```typescript class MsgDedup { private readonly seen = new Map(); // msgId → timestamp has(msgId: string): boolean { const t = this.seen.get(msgId); if (t === undefined) return false; if (Date.now() - t > this.ttlMs) { this.seen.delete(msgId); return false; } return true; } } // ❌ 不要用 Array.includes() — O(n),5000条时慢到不可用 ``` ### 4.3 每用户串行队列 + 深度上限 ```typescript class UserQueue { private readonly tails = new Map>(); private readonly depths = new Map(); enqueue(userId: string, task: () => Promise): boolean { if ((this.depths.get(userId) ?? 0) >= MAX_DEPTH) return false; // 拒绝 // Promise 链串行执行,不同用户并发互不影响 ... } } // MAX_DEPTH = 5:用户排队超5条立即告知,防内存泄漏 ``` ### 4.4 WebSocket 断线指数退避重连 ```typescript private reconnectDelay = 2_000; private scheduleReconnect(): void { setTimeout(() => this.start(), this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60_000); // 成功连接后在 ws.on('open') 里重置:this.reconnectDelay = 2_000; } ``` ### 4.5 OpenClaw Gateway 重连防多开 ```typescript private setupAutoReconnect(): void { const check = setInterval(() => { if (this.client.isConnected() || this.reconnecting) return; // 原子守门 this.reconnecting = true; this.client = new OpenClawClient(...); // 先替换,再重连 const attempt = async () => { try { await this.client.connect(); this.reconnecting = false; } catch { setTimeout(attempt, delay *= 1.5); } // 递归重试,不叠加 }; attempt(); }, 10_000); check.unref(); // 不阻止进程退出 } // ❌ 不要在 catch 里直接重调 this.connect(),会叠加多个重连 loop ``` ### 4.6 优雅关闭 ```typescript process.once('SIGTERM', () => { stream.stop(); tokenMgr.destroy(); // 清除 setTimeout 防止内存泄漏 setTimeout(() => process.exit(0), 3_000); // 3秒内完成清理 }); ``` --- ## 五、supervisord 集成模式 容器内可选 Channel 的标准模式: ```ini [program:dingtalk-channel] command=/app/bridge/start-dingtalk.sh ; 包装脚本,未配置时 exit 0 autostart=true autorestart=unexpected ; ← 关键:不是 true exitcodes=0 ; ← 关键:exit 0 = 正常退出,不重启 startretries=5 startsecs=10 ``` 包装脚本模板 (`start-xxx.sh`): ```bash #!/bin/sh if [ -z "$XXX_CLIENT_ID" ]; then echo "[xxx] Not configured — channel disabled." exit 0 # supervisord 看到 exit 0 → EXITED,不重启 fi exec node /app/bridge/dist/channels/xxx.js ``` --- ## 六、开发新 Channel 的步骤 ### Step 1: 确认平台接入机制 | 问题 | 必须确认 | |------|---------| | 是否需要公网 IP? | 需要 → 考虑反向代理;不需要(WebSocket Stream)→ 最佳 | | 是否有官方 SDK? | 评估:SDK 稳定性 vs 自实现控制力 | | Token 有效期多长? | 决定刷新策略 | | 消息字符上限? | 决定分块策略 | | 是否有消息重投? | 决定去重策略 | | ACK 超时多长? | 必须在超时前 ACK,将 AI 处理异步化 | ### Step 2: 创建文件 ``` packages/openclaw-bridge/src/channels/xxx.ts — 主进程 packages/openclaw-bridge/start-xxx.sh — 启动包装脚本 ``` ### Step 3: 复用现有基础设施 ```typescript import { OpenClawClient } from '../openclaw-client'; // ✓ 直接复用,Protocol v3 已正确实现 // 复用以下 class:TokenManager、UserQueue、MsgDedup、RateLimiter // 只需自己实现:平台 WS 连接 + 消息收发逻辑 ``` ### Step 4: 向 OpenClaw 发消息的标准代码 ```typescript const result = await ocClient.rpc('chat.send', { sessionKey: `agent:main:${PLATFORM_PREFIX}-${userId}`, // 确保跨平台 session 不冲突 message: userText, timeoutSeconds: 25, }, 30_000); const payload = result as { status: string; reply?: string; error?: string }; if (payload.status === 'ok' && payload.reply) { await replyToUser(payload.reply); } else if (payload.status === 'timeout') { await replyToUser('处理超时,请稍后重试。'); } ``` ### Step 5: 更新 supervisord.conf 和 Dockerfile 在 `supervisord.conf` 末尾追加新 program 块(参考钉钉模板)。 在 `Dockerfile` 中 COPY 新的启动脚本并 chmod +x。 ### Step 6: 更新 agent-instance-deploy.service.ts 在 `runDeploy` 的 `envParts` 数组中追加新平台的环境变量: ```typescript if (xxxClientId) envParts.push(`-e XXX_CLIENT_ID=${xxxClientId}`); ``` ### Step 7: 更新 system-prompt-builder.ts 教 iAgent 如何引导用户完成该 Channel 的绑定配置步骤。 --- ## 七、各平台接入难度预估 | 平台 | 接入方式 | 需要公网IP | 预估难度 | 备注 | |------|---------|-----------|---------|------| | **钉钉** | Stream SDK (WSS) | 否 | ⭐⭐ | 已实现,可直接参考 | | **飞书(Lark)** | 事件订阅 WebSocket | 否 | ⭐⭐ | 官方有 Stream SDK,与钉钉结构类似 | | **Telegram** | Long Polling / Webhook | 否(polling)/ 是(webhook)| ⭐ | 最简单,Bot API 文档极好 | | **WhatsApp** | Cloud API Webhook | 是 | ⭐⭐⭐ | 需 Meta 审核,有 webhook 验证 | | **Slack** | Socket Mode | 否 | ⭐⭐ | Socket Mode 无需公网,文档好 | | **Discord** | Gateway WebSocket | 否 | ⭐⭐ | 官方 gateway 长连接,文档清晰 | | **微信** | 服务号 / 企业微信 | 是(公网 + 备案)| ⭐⭐⭐⭐ | 最麻烦,需域名验证 | | **Line** | Messaging API Webhook | 是 | ⭐⭐⭐ | 与 WhatsApp 类似 | **优先推荐顺序**(按落地难度由易到难): 1. Telegram — 最快,1天内可完成 2. 飞书 — 国内办公场景,与钉钉代码高度相似 3. Slack — 国际企业场景 4. Discord — 开发者/社区场景 --- ## 八、经验教训(避坑清单) 1. **永远先读官方源码,不要猜 API 方法名** - 本项目曾把 `chat.send` 猜成 `agent.run`,`gateway.status` 猜成 `metrics.get`,TypeScript 编译通过但 runtime 全部失败 - 查源码路径:github.com/openclaw/openclaw 的 `src/gateway/server-methods/` 2. **Channel token 必须有自动续期机制** - 钉钉 Access Token 2小时过期,不续期则服务必然中断 - 在 `get()` 中加 Promise 互斥锁,防止并发双重刷新 3. **永远在收到消息后立即 ACK,再异步处理 AI** - 钉钉 ACK 超时 1.5 秒,AI 响应可能需要 30 秒 - 先 `ws.send(ACK)`,再 `queue.enqueue()` 异步处理 4. **消息去重用 Map,不用 Array** - `Array.includes()` 是 O(n),5000条消息时每次查找慢 5ms - `Map.get()` 是 O(1),加 TTL 可精确控制内存 5. **supervisord 的 autorestart 要用 unexpected,不是 true** - `autorestart=true` + 未配置时 exit 0 → 无限重启循环 - `autorestart=unexpected` + `exitcodes=0` → 正常退出不重启 6. **每用户消息队列必须有深度上限** - 用户刷消息时,无限队列会耗尽内存 - 建议上限 5 条,超出告知用户"请等待" 7. **OpenClaw 重连不要用嵌套 setInterval + async** - 多个 interval tick 可能同时触发多个重连 goroutine - 用 flag 原子保护 + 单一递归 `attempt()` 函数 8. **回复消息要检查 webhook 过期时间** - 某些平台的 webhook 有效期很短(几分钟到几小时) - AI 处理完后要先检查 `webhookExpiredTime`,过期则不发 9. **容器内没有 curl,用 wget 或 Node.js https 模块** - `hailin168/openclaw-bridge` 镜像内无 curl - `wget -q -O- --post-data=...` 是 iAgent 内部调用 API 的正确方式