通道登錄表
職責
通道登錄表 (channel registry) 是 OpenClaw 把「22 個 chat 通道 + 任意第三方外掛通道」統一成一個可查詢字典的層。它對外只暴露四件事:列出已註冊的通道 id、把使用者輸入的別名 (alias) 規範化 (normalize) 成正規 id、按 id 或 alias 查找一個通道外掛 (channel plugin) 條目、給 setup / status 介面拼一行簡短的介紹文字。
登錄表本身不載入通道的執行時實作——它只持有元資料 (metadata) 與查找索引。真正呼叫 startAccount、sendMessage 這些動作是 通道介面卡 幹的活,登錄表只負責「告訴我 telegram 這個 id 對應哪個 plugin 條目,以及它的別名有哪些」。
設計動機
為什麼單拎一層登錄表而不是直接 import { telegramPlugin } from "..."?三個現實原因:
第一,通道數固定但有別名。telegram 在使用者設定裡可能寫成 tg、Telegram、TELEGRAM,登錄表得把這些都歸一到同一個正規 id,否則下游所有按 id 索引的 map 都要重複做大小寫和別名處理。
第二,通道有 bundled 與 loaded 兩層。bundled 是產生程式碼內自帶的元資料,loaded 是執行時從外掛 npm 套件載入的。loaded 優先於 bundled,這樣裝了 telegram 外掛套件後能 pin 或 override 自帶版本,但沒裝時也能用 bundled 兜底。登錄表得把這兩層合併成同一個查詢視圖。
第三,外掛熱註冊:啟動快照之後註冊的通道也要能被查到(修過 #94127 這類問題)。登錄表不能只在啟動時 build 一次,但每次查詢又不能都重算——所以加了快照版本號 (version) 做快取失效。
關鍵檔案
channels/registry.ts:22-77— 公共 facade,normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine。registry-lookup.ts:42-100— 帶版本號快取的查找視圖,buildRegisteredChannelPluginLookup是熱路徑。channels/ids.ts:21-93— 22 個 bundled 通道 id 與別名的源頭,從 generated metadata 派生。channels/plugins/registry.ts:20-65— 執行時 facade,疊加 bundled fallback。registry-loaded.ts— 已載入通道外掛的真實狀態,被registry.ts呼叫。registry-loader.ts:17-43— 通用惰性值載入器,從一個 channel id 解析出任意的 plugin surface。plugins/bundled.ts:796-900— bundled 通道載入器,出錯只 warn 不拋。plugins/catalog.ts:463-532— UI catalog 建構器,合併 bundled / 官方 / 外部 catalog。bundled-channel-config-metadata.generated.ts— 自動產生的 22 通道元資料,含 schema、別名、order。
資料流
22 個 bundled 通道 id 來自 generated metadata。ids.ts 啟動時把它過濾並排序(ids.ts:21):
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):
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):
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):
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):
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透過getActivePluginChannelRegistrySnapshotFromState拿version。如果 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_METADATA由scripts/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 主循環,再經介面卡發回去,是下一頁的主題。