Skip to content

通道介面卡 ​

源码版本v2026.6.11

職責 ​

通道介面卡 (channel adapter) 是把「22 種不同協定的 chat 平台」翻譯成 OpenClaw 內部統一事件流的膠水層。每個通道實作一組介面卡介面,閘道只跟這組介面打交道,不關心下面是 Telegram polling 還是 Slack Socket Mode 還是 Discord Gateway。

介面卡分三組:

  1. GatewayAdapter — startAccount / stopAccount,負責拉起一個帳號的長連線 (polling / webhook / WebSocket) 並把平台原始事件餵進 agent 主循環;loginWithQrStart / loginWithQrWait / logoutAccount 處理 WhatsApp 這類需要掃碼登入的通道。
  2. MessageAdapter — send.text / send.media / send.payload / send.poll 四個出站方法,加 receive 的 ack 策略宣告、live 的串流預覽能力宣告。這是 outbound 方向的統一傳送介面。
  3. 細分能力 adapter — config / setup / pairing / security / groups / mentions / outbound / status / gateway / auth / approval / elevated / commands / lifecycle / secrets / allowlist / doctor / bindings / conversationBindings / streaming / threading / message / messaging / agentPrompt / directory / resolver / actions / heartbeat,每個都是可選的——通道按能力實作,不實作的就留 undefined。

設計動機 ​

把 gateway / message / 一堆小 adapter 拆開,最直接原因是通道之間的能力差異很大:

Telegram 既支援 webhook 也支援 long polling,WhatsApp 必須掃碼,Slack 走 Socket Mode (WebSocket),Discord 走 Gateway (WebSocket),iMessage 走 AppleScript 本地橋。如果用一個統一 Channel 介面囊括,要嘛塞一堆可選欄位,要嘛每個通道實作大量空方法。

訊息出站方向,有的通道支援 replyToId,有的支援 threadId,有的支援 nativeQuote,有的支援串流預覽 (draft preview)。這些能力是宣告式的——介面卡在 capabilities 裡宣告 nativeStreaming: true,核心程式碼看到才用,看不到就走降級路徑。

startAccount 接收的 ChannelGatewayContext 裡有 abortSignal、channelRuntime、setStatus、getStatus——這些是閘道給通道注入的能力,不是通道自己造的。channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher 就是閘道讓通道把 inbound 事件餵進 agent 主循環的入口。

所以介面卡層的設計是:介面分細 + 全部可選 + 能力宣告式。通道按需實作,核心程式碼按宣告的能力選用策略。

關鍵檔案 ​

資料流 ​

通道啟動從 gateway.startAccount(ctx) 進入(telegram/channel.ts:993):

typescript
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    // ... probe token、resolve bot info
    return startTelegramMonitor({
      token,
      accountId: account.accountId,
      cfg: ctx.cfg,
      runtime: ctx.runtime,
      channelRuntime: ctx.channelRuntime,
      abortSignal: ctx.abortSignal,
      useWebhook: Boolean(account.config.webhookUrl),
      webhookUrl: account.config.webhookUrl,
      webhookSecret: account.config.webhookSecret,
      // ...
    });
  },
  stopAccount: async ({ account, accountId, log }) => {
    const released = await releaseStoppedTelegramPollingLease({ token, accountId });
    if (released) {
      log?.info?.(`[${accountId}] released stopped Telegram polling lease`);
    }
  },
}

注意幾點:ctx.account 是 通道設定 解析後的結構化帳號;ctx.channelRuntime 是閘道注入的能力包,裡面有 reply / routing / text / session / media / commands / groups / pairing 八個子模組;abortSignal 是閘道生命週期訊號,閘道停時要讓長連線立即斷。

Slack 的 startAccount(slack/channel.ts:757)更薄,直接委託給 monitorSlackProvider:botToken + appToken 兩個 token trim 後傳入(appToken 是 Socket Mode 專用、botToken 是普通 bot API 用的),其餘欄位跟 telegram 類似——channelRuntime / abortSignal / setStatus / getStatus。Slack Socket Mode 是 WebSocket 長連線,實作在 monitorSlackProvider 內部;setStatus / getStatus 讓 monitor 回饋帳號執行狀態到 通道登錄表 的快照裡。

Discord 啟動多了一個 rate-limit 防護(discord/channel.ts:690):

typescript
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
  ctx.log?.info(
    `[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
  );
  try {
    await sleepWithAbort(startupDelayMs, ctx.abortSignal);
  } catch {
    return;
  }
}

sleepWithAbort 讓等待期間也能回應 abortSignal——閘道停了就立即放棄啟動,不要傻等完再退出。Discord 的 token 狀態也要先校驗,configured_unavailable 拋錯(discord/channel.ts:685),避免用未解析的 SecretRef 啟動。

WhatsApp 的 QR 登入是兩段式(whatsapp/channel.ts:345):loginWithQrStart 拉起 web login 返回 QR Code,loginWithQrWait 輪詢等待使用者掃碼完成,logoutAccount 呼叫 logoutWeb 清 authDir。兩個方法配合讓 setup 介面渲染 QR Code 並阻塞等待結果,而不是把整個登入塞在一個超長 promise 裡。

出站方向走 MessageAdapter(message/types.ts:306):

typescript
export type ChannelMessageSendAdapter<
  TConfig = OpenClawConfig,
  TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
  text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
  media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
  payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
  poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
  lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};

四個方法對應四種出站 payload:text 純文字、media 媒體、payload 富卡片、poll 投票。lifecycle 是 hook: beforeSend / afterSendSuccess / afterSendFailure / afterCommit,給通道在傳送前後插自己的邏輯(比如 Slack 要更新 parent message 的 reply 計數)。

接收方向 (inbound) 走 ack 策略(message/types.ts:374):

typescript
export type ChannelMessageReceiveAckPolicy =
  | "after_receive_record"
  | "after_agent_dispatch"
  | "after_durable_send"
  | "manual";

四種策略控制「什麼時候向平台 ack 這條訊息已收到」——after_receive_record 是最早、剛落庫就 ack;after_agent_dispatch 是 agent 拿到事件後;after_durable_send 是回覆 durably 投遞成功後;manual 是通道自己控制。defineChannelMessageAdapter 預設給 manual(message/adapter.ts:12),因為大多數通道需要自己決定 ack 時機。

整個 inbound → outbound 鏈路:

邊界與失敗 ​

  • SecretRef 未解析就啟動會拋:Discord 在 startAccount 裡檢查 tokenStatus === "configured_unavailable"(discord/channel.ts:685),直接拋錯而不是用空 token 去連,避免日誌洩漏「token 是空字串」這種邊界狀態。
  • polling lease 釋放:Telegram 的 stopAccount 必須呼叫 releaseStoppedTelegramPollingLease(telegram/channel.ts:1094),否則重啟時上一輪的 polling lease 還在,會撞 token 衝突。
  • rate limit 主動延遲:Discord 啟動前 startupDelayMs 等待且能被 abortSignal 中斷(discord/channel.ts:696)。這是把「防止 Discord 限制並發啟動」做成顯式 sleep,而不是失敗重試。
  • bundled 載入失敗只 warn:describeBundledChannelLoadError(bundled.ts:318)提示跑 openclaw doctor --fix,一個通道掛了不影響其他通道。
  • channelRuntime 可選:ChannelGatewayContext.channelRuntime 是可選欄位,外部外掛應該檢查 undefined 再用(types.adapters.ts:313),否則 SDK 版本不對會 NPE。
  • ack 預設 manual:defineChannelMessageAdapter 把 receive 預設成 manual(message/adapter.ts:12),通道不主動宣告 ack 策略時核心程式碼不會替它自動 ack——防止「訊息被以為已處理其實丟了」。
  • capabilities 宣告式降級:durableFinalDeliveryCapabilities(message/types.ts:17)列出 12 個能力,通道在 capabilities 裡宣告 true 的核心程式碼才會走對應可靠投遞路徑,沒宣告的走降級,不拋錯。

小結 ​

介面卡層把 22 個通道的差異封裝在一組細粒度、全部可選、能力宣告式的介面背後。GatewayAdapter 管啟停與登入,MessageAdapter 管出站傳送與入站 ack,細分 adapter 各管一個能力切面。閘道透過 channelRuntime 注入能力,透過 abortSignal 控制生命週期,透過 capabilities 宣告式選擇策略。

怎麼從 通道登錄表 拿到 plugin 條目再呼叫它的 startAccount,以及 agent 事件怎麼到 MessageAdapter 發回平台,看 聊天廣播。每個通道在 openclaw.json 裡怎麼配 token、allowFrom、accounts,看 通道設定。