Skip to content

通道登錄表

源码版本v2026.6.11

職責

通道登錄表 (channel registry) 是 OpenClaw 把「22 個 chat 通道 + 任意第三方外掛通道」統一成一個可查詢字典的層。它對外只暴露四件事:列出已註冊的通道 id、把使用者輸入的別名 (alias) 規範化 (normalize) 成正規 id、按 id 或 alias 查找一個通道外掛 (channel plugin) 條目、給 setup / status 介面拼一行簡短的介紹文字。

登錄表本身不載入通道的執行時實作——它只持有元資料 (metadata) 與查找索引。真正呼叫 startAccountsendMessage 這些動作是 通道介面卡 幹的活,登錄表只負責「告訴我 telegram 這個 id 對應哪個 plugin 條目,以及它的別名有哪些」。

設計動機

為什麼單拎一層登錄表而不是直接 import { telegramPlugin } from "..."?三個現實原因:

第一,通道數固定但有別名telegram 在使用者設定裡可能寫成 tgTelegramTELEGRAM,登錄表得把這些都歸一到同一個正規 id,否則下游所有按 id 索引的 map 都要重複做大小寫和別名處理。

第二,通道有 bundled 與 loaded 兩層。bundled 是產生程式碼內自帶的元資料,loaded 是執行時從外掛 npm 套件載入的。loaded 優先於 bundled,這樣裝了 telegram 外掛套件後能 pin 或 override 自帶版本,但沒裝時也能用 bundled 兜底。登錄表得把這兩層合併成同一個查詢視圖。

第三,外掛熱註冊:啟動快照之後註冊的通道也要能被查到(修過 #94127 這類問題)。登錄表不能只在啟動時 build 一次,但每次查詢又不能都重算——所以加了快照版本號 (version) 做快取失效。

關鍵檔案

資料流

22 個 bundled 通道 id 來自 generated metadata。ids.ts 啟動時把它過濾並排序(ids.ts:21):

typescript
function listBundledChatChannelEntries(): BundledChatChannelEntry[] {
  return GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false)
    .map((entry) => ({
      id: normalizeOptionalLowercaseString(entry.channelId) ?? entry.channelId,
      aliases: entry.aliases ?? [],
      order: entry.order ?? Number.MAX_SAFE_INTEGER,
    }))
    .toSorted(
      (left, right) =>
        left.order - right.order || left.id.localeCompare(right.id, "en", { sensitivity: "base" }),
    );
}

實際 22 個 id 是:clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser。

規範化 (normalization) 走「alias 表 → runtime catalog」兩級回退(ids.ts:84):

typescript
export function normalizeChatChannelId(raw?: string | null): ChatChannelId | null {
  const normalized = normalizeOptionalLowercaseString(raw);
  if (!normalized) {
    return null;
  }
  const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized;
  return CHAT_CHANNEL_ID_SET.has(resolved)
    ? resolved
    : normalizeRuntimeBundledChatChannelId(normalized);
}

這裡先用編譯期產生的 CHAT_CHANNEL_ALIASES 命中常見別名,沒命中再走 listRuntimeBundledChatChannelEntries 兜底——後者會讀 bundled-channel-catalog-read.ts,覆蓋那些沒在編譯期產生、但執行時動態註冊了的 bundled metadata。

執行時查找由 registry-lookup.ts 用快照+版本號快取(registry-lookup.ts:42):

typescript
function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
  const { registry, version } = getActivePluginChannelRegistrySnapshotFromState();
  const channels = Array.isArray(registry?.channels) ? registry.channels : undefined;
  const channelCount = channels?.length ?? 0;
  const cached = registeredChannelPluginLookup;
  if (
    cached &&
    cached.registry === registry &&
    cached.channels === channels &&
    cached.channelCount === channelCount &&
    cached.version === version
  ) {
    return cached;
  }
  // ... 重建 byKey / byId 索引
}

注意重建條件用了四個參照相等加一個 version。version 是 plugin registry 推進的整數,每次熱註冊一個新 plugin 時 bump,快取就自動失效——避免每次查 registry 都重算 byKey Map。

索引填充時先到先得(registry-lookup.ts:31):

typescript
function setLookupEntry(
  map: Map<string, RegisteredChannelPluginEntry>,
  key: string | undefined,
  entry: RegisteredChannelPluginEntry,
): void {
  // First writer wins so canonical ids keep priority over later aliases.
  if (key && !map.has(key)) {
    map.set(key, entry);
  }
}

這條規則保證正規 id 永遠指向最早註冊的那一條,後註冊的同名 alias 不能搶占——這對熱插拔場景很關鍵,防止後裝的外掛意外覆蓋 bundled 通道。

查找通道外掛本體走 getChannelPlugin,先查 loaded 再回退到 bundled(plugins/registry.ts:49):

typescript
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
  const resolvedId = normalizeOptionalString(id) ?? "";
  if (!resolvedId) {
    return undefined;
  }
  // Loaded plugins win over bundled fallbacks so installed plugin state can pin
  // or override a bundled channel during runtime.
  return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId);
}

bundled 載入失敗時只 warn 不拋(plugins/bundled.ts:679):log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`)然後快取null。下一次查同 id 直接返回 undefined,不會反覆觸發載入——這是給「執行時依賴缺失」留的容錯空間,describeBundledChannelLoadError還會提示使用者跑openclaw doctor --fix`。

整個查找鏈路:

邊界與失敗

  • 快取失效靠 version:buildRegisteredChannelPluginLookup 透過 getActivePluginChannelRegistrySnapshotFromStateversion。如果 plugin registry 推進 version 但忘了通知 channel state,熱註冊的通道會查不到。
  • First writer wins:別名先到先得。如果兩個 plugin 註冊了同 id,只有第一個生效;後來者會被靜默忽略——所以通道 id 命名衝突不會拋錯,只會「看起來裝了但其實沒生效」。
  • bundled 載入失敗只 warn:missing module 錯誤會提示跑 openclaw doctor --fix(bundled.ts:318),但不阻斷啟動。這是為了讓一個通道掛了不影響其餘 21 個。
  • bundled fallback 只對內建 22 通道生效:第三方外掛通道不進 generated metadata,如果 loaded 沒裝,getBundledChannelPlugin 直接返回 undefined——沒有兜底。
  • runtime catalog 兜底:listRuntimeBundledChatChannelEntries??= 快取一次(ids.ts:61),首次呼叫讀 catalog 檔案,之後只用記憶體。如果 catalog 檔案執行時被替換,要重啟進程才能感知。
  • 產生時機:GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAscripts/generate-bundled-channel-config-metadata.ts 在建置期產生,加新通道必須重跑產生指令碼,否則 CHAT_CHANNEL_ID_SET 不含新 id。

小結

登錄表只做「列、歸一、查、介紹」四件事,不碰通道執行時。它透過 generated metadata + runtime catalog 兩級兜底覆蓋 22 個 bundled 通道加任意第三方外掛通道,透過快照 version 快取避免每次查詢重算索引,透過 first-writer-wins 保證熱註冊不會覆蓋已有正規 id。

真正呼叫通道能力的地方看 通道介面卡,使用者在 openclaw.json 裡怎麼配這些通道看 通道設定,外掛系統的整體機制看 能力外掛。通道怎麼把訊息送進 閘道核心 走 agent 主循環,再經介面卡發回去,是下一頁的主題。