Skip to content

OpenClaw Channel 开发指南:从零打造 WebSocket 通道

来源:OpenClaw 官方文档(docs.openclaw.ai)、Taichi-Labs/openclaw-websocket、掘金 Channel 插件开发实战指南、zeeklog.com 实战案例 整理日期:2026-04-07


为什么要开发自定义 Channel

OpenClaw 内置了 20+ 个消息渠道(Telegram、Discord、飞书、QQ Bot 等),但有时你需要接入自己的系统——比如内部 IM、自建 Web 聊天界面、或者任何通过 WebSocket 通信的服务。这时就需要开发一个自定义 Channel Plugin。

Channel Plugin 是 OpenClaw 插件系统中的一类,它的职责很明确:与外部消息平台建立连接,接收用户消息,将消息传递给 OpenClaw 进行 AI 处理,再把 AI 回复发送回用户。


核心概念:三个对象的关系

开发 Channel Plugin 前,必须先理解三个核心对象的关系,否则很容易在配置时搞混。

Plugin 对象(如 myPlugin)是插件的入口,负责向 OpenClaw 注册 Channel。它的 id 用于 plugins.installsplugins.allow 配置层级。

Channel 对象(如 myChannel)是 Channel 的完整功能实现,包含消息收发逻辑、账户管理、状态管理等。它的 id 用于 channelsbindings 配置层级。

ChannelDock 对象(如 myDock)是轻量级的能力声明,告诉 OpenClaw 这个 Channel 支持哪些聊天类型、是否支持流式响应等。

用一个类比来理解:Plugin 是公司(整体注册认证),Channel 是销售部(具体业务处理),ChannelDock 是资质证书(能力声明)。


整体架构

一个典型的 WebSocket Channel 项目由三层组成:

用户浏览器(前端 UI)
    ↕ WebSocket
后端服务(消息中转,可选)
    ↕ WebSocket
OpenClaw Channel Plugin(Node.js)
    ↕ Plugin SDK API
OpenClaw Gateway

AI Provider(LLM)

如果你的场景比较简单,可以省略后端服务,让前端直接连接 Channel Plugin 暴露的 WebSocket 端口。


开发环境准备

bash
# 环境要求
# Node.js >= 18.0.0
# OpenClaw >= 2026.3.12

# 创建插件目录
mkdir my-ws-channel
cd my-ws-channel

# 初始化项目
npm init -y

# 安装依赖
npm install openclaw
npm install ws
npm install -D typescript @types/node @types/ws

配置 tsconfig.json

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

项目结构

my-ws-channel/
├── src/
│   ├── channel.ts          # Channel Plugin 核心实现
│   ├── runtime.ts          # 运行时存储(单例)
│   ├── types.ts            # 类型定义
│   └── websocket-client.ts # WebSocket 客户端封装(可选)
├── index.ts                # 插件入口(register 函数)
├── package.json
├── tsconfig.json
└── openclaw.plugin.json    # 插件清单(必须)

第一步:定义插件清单

openclaw.plugin.json 是 OpenClaw 在不执行代码的情况下验证插件的依据,必须提供。

json
{
  "id": "my-ws-channel",
  "name": "My WebSocket Channel",
  "description": "Custom WebSocket messaging channel for OpenClaw",
  "channels": ["my-ws-channel"],
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "enabled": { "type": "boolean" },
      "config": {
        "type": "object",
        "properties": {
          "wsUrl": { "type": "string" },
          "enabled": { "type": "boolean" }
        }
      }
    }
  }
}

关键点:channels 字段必须声明这个插件会注册的渠道 id,否则 OpenClaw 会把未知的 channels.* 键视为错误。


第二步:定义类型

src/types.ts

typescript
// WebSocket 消息格式(与你的服务端约定)
export interface WebSocketMessage {
  type: string;
  text?: string;
  to?: string;
  from?: string;
  messageId?: string;
  payload?: {
    content?: string;
    messageId?: string;
    to?: string;
  };
}

// Channel 账户配置
export interface WsChannelAccount {
  accountId: string;
  wsUrl: string;
  enabled?: boolean;
  configured?: boolean;
}

第三步:运行时存储

src/runtime.ts

typescript
import type { PluginRuntime } from "openclaw/plugin-sdk";

let runtime: PluginRuntime | null = null;

export function setRuntime(next: PluginRuntime) {
  runtime = next;
}

export function getRuntime(): PluginRuntime {
  if (!runtime) {
    throw new Error("Plugin runtime not initialized");
  }
  return runtime;
}

这个模块用于在插件 register 时保存 api.runtime,供 Channel 的 startAccount 方法使用。


第四步:实现 Channel Plugin(核心)

src/channel.ts

typescript
import type { ReplyPayload } from "openclaw/auto-reply/types";
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import { createDefaultChannelRuntimeState } from "openclaw/plugin-sdk";
import type { WsChannelAccount } from "./types.js";
import { getRuntime } from "./runtime.js";

// 存储活跃的 WebSocket 连接
const connections = new Map<string, { ws: any; accountId: string }>();

export const myWsChannel: ChannelPlugin<WsChannelAccount> = {
  // ── 1. 基础元数据 ──────────────────────────────────────────────
  id: "my-ws-channel",
  meta: {
    id: "my-ws-channel",
    label: "My WebSocket Channel",
    selectionLabel: "My WebSocket Channel (Custom)",
    docsPath: "/channels/my-ws-channel",
    blurb: "Custom WebSocket messaging channel.",
    aliases: ["ws-channel"],
  },

  // ── 2. 能力声明 ────────────────────────────────────────────────
  capabilities: {
    chatTypes: ["direct"],   // 支持私聊
    blockStreaming: true,     // 支持流式响应
  },

  // ── 3. 账户配置适配器 ──────────────────────────────────────────
  config: {
    // 列出所有账户 ID(简单场景固定返回 ["default"])
    listAccountIds: (cfg: OpenClawConfig) => {
      return ["default"];
    },

    // 从 openclaw.json 中解析账户配置
    resolveAccount: (cfg: OpenClawConfig, accountId: string) => {
      const channelCfg = cfg.channels?.["my-ws-channel"];
      if (!channelCfg || !channelCfg.config) {
        return undefined;
      }
      const config = channelCfg.config as any;
      return {
        accountId: "default",
        wsUrl: config.wsUrl || "ws://localhost:8765/openclaw",
        enabled: config.enabled !== false,
      };
    },

    // 判断账户是否已配置(wsUrl 非空即视为已配置)
    isConfigured: async (account, cfg) => {
      return Boolean(account.wsUrl && account.wsUrl.trim() !== "");
    },
  },

  // ── 4. 状态管理适配器 ──────────────────────────────────────────
  // ⚠️ 必须实现 defaultRuntime,否则 UI 会显示 "0/1 connected"
  status: {
    defaultRuntime: createDefaultChannelRuntimeState("default", {
      wsUrl: null,
      connected: false,
      groupPolicy: null,
    }),

    buildChannelSummary: ({ snapshot }) => ({
      wsUrl: snapshot.wsUrl ?? null,
      connected: snapshot.connected ?? null,
      groupPolicy: snapshot.groupPolicy ?? null,
    }),

    buildAccountSnapshot: ({ account, runtime }) => ({
      accountId: account.accountId,
      enabled: account.enabled,
      configured: account.configured,
      wsUrl: account.wsUrl,
      running: runtime?.running ?? false,
      connected: runtime?.connected ?? false,
      groupPolicy: runtime?.groupPolicy ?? null,
      lastStartAt: runtime?.lastStartAt ?? null,
      lastStopAt: runtime?.lastStopAt ?? null,
      lastError: runtime?.lastError ?? null,
    }),
  },

  // ── 5. 出站消息处理 ────────────────────────────────────────────
  outbound: {
    sendText: async ({ accountId, to, text }) => {
      const conn = connections.get(accountId);
      if (!conn || conn.ws.readyState !== 1) {
        throw new Error(`No active WebSocket connection for account ${accountId}`);
      }
      conn.ws.send(JSON.stringify({
        type: "message",
        to,
        content: text,
      }));
    },
  },

  // ── 6. 网关适配器(核心:启动连接、处理消息)─────────────────
  gateway: {
    startAccount: async (ctx) => {
      const { log, account, abortSignal, cfg } = ctx;
      const runtime = getRuntime();

      log?.info(`[my-ws-channel] Starting for account: ${account.accountId}`);

      // ⭐ 关键:先设置状态为已连接,UI 才能正确显示
      ctx.setStatus({
        accountId: account.accountId,
        wsUrl: account.wsUrl,
        running: true,
        connected: true,
      });

      // 动态导入 ws 库(避免顶层 import 在某些环境下的问题)
      const WebSocketLib = await import("ws");
      const ws = new (WebSocketLib.default as any)(account.wsUrl);

      // 存储连接
      connections.set(account.accountId, { ws, accountId: account.accountId });

      // 用 Promise 控制生命周期
      let resolveLifecycle: () => void;
      let rejectLifecycle: (err: Error) => void;
      const lifecyclePromise = new Promise<void>((resolve, reject) => {
        resolveLifecycle = resolve;
        rejectLifecycle = reject;
      });

      // ── 监听入站消息 ──────────────────────────────────────────
      ws.on("message", async (data: Buffer) => {
        try {
          const rawData = data.toString();
          const eventData = JSON.parse(rawData);
          const innerData = eventData.data || {};

          // 标准化消息格式
          const normalizedMessage = {
            id: `${eventData.source || "ws"}-${Date.now()}`,
            channel: "my-ws-channel",
            accountId: account.accountId,
            senderId: innerData.source || eventData.source || "unknown",
            senderName: innerData.source || eventData.source || "Unknown",
            text: innerData.content || innerData.text || "",
            timestamp: innerData.timestamp || new Date().toISOString(),
            isGroup: false,
            groupId: undefined,
            attachments: [],
            metadata: {},
          };

          log?.info(`[my-ws-channel] Received: "${normalizedMessage.text}" from ${normalizedMessage.senderId}`);

          // 解析路由(确定消息应该发给哪个 Agent)
          const route = runtime.channel.routing.resolveAgentRoute({
            cfg,
            channel: "my-ws-channel",
            accountId: account.accountId,
            peer: {
              kind: "direct",
              id: normalizedMessage.senderId,
            },
          });

          // 构建消息上下文
          const ctxPayload = runtime.channel.reply.finalizeInboundContext({
            Body: normalizedMessage.text,
            BodyForAgent: normalizedMessage.text,
            From: normalizedMessage.senderId,
            To: undefined,
            SessionKey: route.sessionKey,
            AccountId: route.accountId,
            ChatType: "direct",
            SenderName: normalizedMessage.senderName,
            SenderId: normalizedMessage.senderId,
            Provider: "my-ws-channel",
            Surface: "my-ws-channel",
            MessageSid: normalizedMessage.id,
            Timestamp: Date.now(),
          });

          // 调度 AI 处理并回复
          await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
            ctx: ctxPayload,
            cfg: cfg,
            dispatcherOptions: {
              deliver: async (payload: ReplyPayload, { kind }) => {
                log?.info(`[my-ws-channel] Delivering ${kind} reply...`);
                const currentConn = connections.get(account.accountId);
                if (!currentConn || currentConn.ws.readyState !== 1) {
                  throw new Error("No WebSocket connection available");
                }
                // 将 AI 回复发送回 WebSocket 客户端
                currentConn.ws.send(JSON.stringify({
                  type: "reply",
                  content: payload.text || "",
                  kind,
                }));
              },
              onError: (err, { kind }) => {
                log?.error(`[my-ws-channel] Delivery error for ${kind}: ${err.message}`);
              },
            },
          });

          log?.info(`[my-ws-channel] Message dispatched successfully`);
        } catch (err: any) {
          log?.error(`[my-ws-channel] Failed to process message: ${err.message}`);
        }
      });

      // ── 错误与关闭处理 ────────────────────────────────────────
      ws.on("error", (err: Error) => {
        log?.error(`[my-ws-channel] WebSocket error: ${err.message}`);
        connections.delete(account.accountId);
        ctx.setStatus({ connected: false, running: false });
        rejectLifecycle(err);
      });

      ws.on("close", () => {
        log?.info(`[my-ws-channel] Connection closed`);
        connections.delete(account.accountId);
        ctx.setStatus({ connected: false, running: false });
        resolveLifecycle();
      });

      // ── 中止信号处理 ──────────────────────────────────────────
      abortSignal.addEventListener("abort", () => {
        log?.info(`[my-ws-channel] Abort requested, closing connection`);
        ws.close();
        resolveLifecycle();
      });

      // 保持 startAccount 挂起,直到连接关闭或被中止
      await lifecyclePromise;
      connections.delete(account.accountId);
    },
  },
};

第五步:插件入口

index.ts

typescript
import { myWsChannel } from "./src/channel.js";
import { setRuntime } from "./src/runtime.js";

export default function register(api: any) {
  console.log("[my-ws-channel] Registering WebSocket Channel plugin");

  // 保存 runtime 供 channel 使用
  setRuntime(api.runtime);

  // 注册 Channel
  api.registerChannel({
    plugin: myWsChannel,
  });
}

第六步:配置 OpenClaw

编辑 ~/.openclaw/config.json(或通过 Web UI 配置):

json
{
  "plugins": {
    "allow": ["my-ws-channel"],
    "installs": {
      "my-ws-channel": {
        "source": "path",
        "path": "/absolute/path/to/my-ws-channel"
      }
    }
  },
  "channels": {
    "my-ws-channel": {
      "enabled": true,
      "config": {
        "enabled": true,
        "wsUrl": "ws://localhost:8765/openclaw"
      }
    }
  },
  "bindings": [
    {
      "agentId": "main",
      "match": {
        "channel": "my-ws-channel"
      }
    }
  ]
}

第七步:安装与启动

bash
# 进入插件目录
cd my-ws-channel

# 安装到 OpenClaw(本地开发用 -l 做 link,不复制文件)
openclaw plugins install -l .

# 验证安装
openclaw plugins list

# 启用插件
openclaw plugins enable my-ws-channel

# 重启 Gateway(配置变更必须重启)
openclaw gateway restart

# 验证 Channel 状态
openclaw channels status

关键细节与常见坑

defaultRuntime 不能省略

这是最容易踩的坑。OpenClaw 的 UI 通过读取 defaultRuntime 来知道要跟踪哪些状态字段。如果省略这个配置,即使你在 startAccount 中调用了 ctx.setStatus({ connected: true }),UI 也只会显示 "0/1 connected"。

正确做法是:在 defaultRuntime 中声明所有要跟踪的字段(包括 connected: false),然后在 startAccount 开始时调用 ctx.setStatus({ connected: true })

startAccount 必须保持挂起

startAccount 是一个异步函数,OpenClaw 会等待它 resolve 才认为账户已停止。如果你的函数立即 return,Gateway 会认为账户已关闭。

正确做法是用一个 Promise 控制生命周期,在 WebSocket 关闭或收到 abortSignal 时才 resolve。

channels 字段必须在清单中声明

openclaw.plugin.jsonchannels 字段必须列出这个插件会注册的所有渠道 id。如果 openclaw.jsonchannels 层级出现了清单中未声明的 id,OpenClaw 会报错。

配置变更必须重启

这是最常见的"为什么没反应"根因。任何配置变更(包括 channels.* 配置)都必须重启 Gateway 才生效。

Plugin id 与 Channel id 的作用域不同

Plugin id(plugins.installsplugins.allow)和 Channel id(channelsbindings)是两个不同的作用域。在简单场景下两者可以相同,但要清楚它们分别控制什么。


完整数据流

入站(用户 → AI):

  1. 外部服务通过 WebSocket 发送消息到 Channel Plugin
  2. ws.on("message") 接收并解析消息
  3. 调用 runtime.channel.routing.resolveAgentRoute 确定路由
  4. 调用 runtime.channel.reply.finalizeInboundContext 构建消息上下文
  5. 调用 runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher 触发 AI 处理

出站(AI → 用户):

  1. AI 生成回复
  2. dispatchReplyWithBufferedBlockDispatcherdeliver 回调触发
  3. Channel Plugin 通过 WebSocket 将回复发送回外部服务

多账户支持

如果需要支持多个 WebSocket 连接(例如连接多个不同的服务),可以扩展 config.listAccountIdsconfig.resolveAccount

typescript
config: {
  listAccountIds: (cfg: OpenClawConfig) => {
    const channelCfg = cfg.channels?.["my-ws-channel"];
    const accounts = channelCfg?.accounts || {};
    return ["default", ...Object.keys(accounts)];
  },

  resolveAccount: (cfg: OpenClawConfig, accountId: string) => {
    const channelCfg = cfg.channels?.["my-ws-channel"];
    if (accountId === "default") {
      return {
        accountId: "default",
        wsUrl: channelCfg?.config?.wsUrl || "ws://localhost:8765/openclaw",
        enabled: true,
      };
    }
    const accountCfg = channelCfg?.accounts?.[accountId];
    if (!accountCfg) return undefined;
    return {
      accountId,
      wsUrl: accountCfg.wsUrl,
      enabled: accountCfg.enabled !== false,
    };
  },
}

对应的 openclaw.json 配置:

json
{
  "channels": {
    "my-ws-channel": {
      "enabled": true,
      "config": {
        "wsUrl": "ws://service-a:8765/openclaw"
      },
      "accounts": {
        "service-b": {
          "wsUrl": "ws://service-b:8766/openclaw",
          "enabled": true
        }
      }
    }
  }
}

调试技巧

bash
# 查看 Gateway 日志(实时)
openclaw gateway logs --follow

# 查看插件详情和状态
openclaw plugins info my-ws-channel

# 全局诊断
openclaw plugins doctor

# 配置语法检查
openclaw config validate

# 查看 Channel 状态
openclaw channels status

startAccount 中大量使用 log?.info(...) 是最直接的调试方式,日志会出现在 Gateway 日志中。


参考资源