Skip to content

通道介面卡

源码版本v2026.6.11

職責

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

介面卡分三組:

  1. GatewayAdapterstartAccount / stopAccount,負責拉起一個帳號的長連線 (polling / webhook / WebSocket) 並把平台原始事件餵進 agent 主循環;loginWithQrStart / loginWithQrWait / logoutAccount 處理 WhatsApp 這類需要掃碼登入的通道。
  2. MessageAdaptersend.text / send.media / send.payload / send.poll 四個出站方法,加 receive 的 ack 策略宣告、live 的串流預覽能力宣告。這是 outbound 方向的統一傳送介面。
  3. 細分能力 adapterconfig / 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 裡有 abortSignalchannelRuntimesetStatusgetStatus——這些是閘道給通道注入的能力,不是通道自己造的。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 呼叫 logoutWebauthDir。兩個方法配合讓 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,看 通道設定