通道設定
職責
通道設定 (channel config) 是 openclaw.json 裡 channels.* 這一坨欄位的解析與匹配層。它管三件事:把使用者寫的 JSON 解析成結構化型別、按通道 id 匹配出帳號設定 (account config)、把 token / botToken / webhookSecret 這類敏感欄位從硬編碼字串升級成 secret reference (env / file / exec)。
它不做「哪個通道該啟動」的決策——那是 通道登錄表 配合 config-presence.ts 幹的。它只負責「使用者寫了什麼、寫得對不對、按什麼鍵匹配到哪條帳號設定」。
設計動機
四個現實原因。
第一,22 個通道欄位各不相同。Telegram 有 botToken + webhookUrl + pollingStallThresholdMs,Slack 有 botToken + appToken + socketMode,WhatsApp 有 authDir,Discord 有 guilds + channels。塞進一個 ChannelsConfig 型別會變成幾千行聯合型別,改一個通道要動核心型別。
第二,多帳號。Telegram 可掛多 bot,Slack 可掛多 workspace,每個帳號獨立設定。需要 accounts: Record<string, AccountConfig> + defaultAccount: string 的統一模式,各通道欄位下推到 account 層。
第三,secret 不能硬編碼。botToken: "123456:ABC..." 寫在 openclaw.json 裡一旦檔案洩露 token 就洩了。SecretInputSchema = z.union([z.string(), SecretRefSchema]) 讓使用者既能寫老式 inline 字串,也能寫 {source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN"} 這種結構化引用。
第四,設定匹配要支援 direct / parent / wildcard。同一個通道可能有多條設定:具體帳號 id 一份、父級通道一份、萬用字元一份。解析時按優先級回退,並標記 matchSource 讓下游知道命中了哪一級。
關鍵檔案
channel-config.ts:60-165—resolveChannelEntryMatch/resolveChannelEntryMatchWithFallback,匹配 direct / parent / wildcard。zod-schema.channels-config.ts:52-69—ChannelsSchema頂層,passthrough 容忍外掛欄位。zod-schema.core.ts:33-90—SecretRefSchema/SecretInputSchema,三種 secret source。zod-schema.providers-core.ts:249-409—TelegramAccountSchemaBase/TelegramConfigSchema,多帳號 schema 範例。SlackConfigSchema 位置:1054— Slack 多帳號 schema。WhatsAppConfigSchema:238— WhatsApp 走z.preprocess相容老設定。types.channels.ts:127-146—ChannelsConfig介面,typed 欄位 + open-world index signature。plugins/config-schema.ts:27-49—AllowFromEntrySchema/buildCatchallMultiAccountChannelSchema公共建構器。config-presence.ts:47-66—hasMeaningfulChannelConfig/listExplicitlyDisabledChannelIdsForConfig。
資料流
openclaw.json 頂層 channels 欄位走 ChannelsSchema(zod-schema.channels-config.ts:52):
export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
.object({
defaults: z
.object({
groupPolicy: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
botLoopProtection: ChannelBotLoopProtectionSchema.optional(),
})
.strict()
.optional(),
modelByChannel: ChannelModelByChannelSchema,
})
.passthrough() // Allow extension channel configs (nostr, matrix, zalo, etc.)
.superRefine((value, ctx) => {
addLegacyChannelAcpBindingIssues(value, ctx);
})
.optional() as z.ZodType<ChannelsConfig | undefined>;頂層只有 defaults 和 modelByChannel 兩個 strict 欄位,其餘全走 passthrough——channels.telegram / channels.slack / channels.nostr / channels.zalo 這些鍵在頂層 schema 這裡不做欄位級校驗,欄位校驗下放到各通道自己的 TelegramConfigSchema / SlackConfigSchema / WhatsAppConfigSchema。.superRefine 只掃描遺留的 bindings.acp 欄位(已廢棄),有就報錯讓使用者遷移到頂層 bindings[]。
為什麼用 passthrough?因為外掛通道是開放世界 (open-world)——types.channels.ts(types.channels.ts:127)的 ChannelsConfig 介面把 10 個核心通道 (discord / googlechat / imessage / irc / msteams / signal / slack / telegram / whatsapp 等) 寫成 typed 欄位,其餘全走 [key: string]: OpenWorldChannelConfig,OpenWorldChannelConfig 就是 ReturnType<typeof JSON.parse>,完全開放。
Secret 引用是設定層的核心抽象(zod-schema.core.ts:83):
/** Config-level secret reference schema shared by model/provider/plugin credential fields. */
export const SecretRefSchema = z.discriminatedUnion("source", [
EnvSecretRefSchema,
FileSecretRefSchema,
ExecSecretRefSchema,
]);
/** Accepts either legacy inline secret strings or structured secret references. */
export const SecretInputSchema = z.union([z.string(), SecretRefSchema]);SecretRefSchema 用 discriminatedUnion("source", ...),三種 source:env 從環境變數讀,id 匹配 /^[A-Z][A-Z0-9_]{0,127}$/ 如 TELEGRAM_BOT_TOKEN;file 從檔案讀,id 是 JSON pointer 如 /providers/openai/apiKey 或單值模式的 value;exec 呼叫外部指令拿 secret。
SecretInputSchema 是 z.union([z.string(), SecretRefSchema])——老式 inline 字串仍相容,但 .register(sensitive) 把欄位標記為敏感(zod-schema.providers-core.ts:269):
botToken: SecretInputSchema.optional().register(sensitive),這樣 openclaw doctor / 日誌輸出時會把 botToken 字面值脫敏。
每個通道自己的 schema 把通用欄位 + 通道專屬欄位組合起來,Telegram 是最典型的多帳號例子(zod-schema.providers-core.ts:405):
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
// ...
});
});TelegramAccountSchemaBase 是通道級欄位集合,TelegramConfigSchema 在此基礎上加 accounts 多帳號容器和 defaultAccount——buildCatchallMultiAccountChannelSchema (plugins/config-schema.ts:42) 是這種模式的通用 helper:
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(
accountSchema: T,
): T {
return accountSchema.extend({
accounts: z.object({}).catchall(accountSchema).optional(),
defaultAccount: z.string().optional(),
}) as T;
}catchall(accountSchema) 讓 accounts 下任意 key 都按 accountSchema 校驗——這樣加新帳號不需要改 schema。
匹配設定時走 resolveChannelEntryMatchWithFallback(channel-config.ts:83),核心是按優先級回退——direct 命中直接返回 { matchKey, matchSource: "direct" };沒命中就走 normalized direct (用 normalizeKey 歸一 key 再匹配),再走 parent、normalized parent,最後落 wildcard。每一步都把 matchKey 和 matchSource 寫進結果,下游用 applyChannelMatchMeta 把這兩個欄位複製到最終 config 物件上,方便審計「這條設定是從哪個 key 命中的」。
normalizeKey 是可選的歸一函式,比如 normalizeChannelSlug(channel-config.ts:47)把 #general、General、GENERAL ROOM 都歸一成 general——使用者在設定裡寫 channel 名時不強求精確匹配。
hasMeaningfulChannelConfig 區分「設定了內容」和「只是 enabled=false」(config-presence.ts:47):它檢查 Object.keys(value).some((key) => key !== "enabled")——enabled 單獨不算「有設定」,只是營運意圖,這樣 openclaw status 不會把「顯式禁用」的通道誤報為「已設定未啟用」。
整個設定解析與匹配流程:
邊界與失敗
- passthrough 容忍外掛欄位:
ChannelsSchema.passthrough()(zod-schema.channels-config.ts:65)讓channels.nostr/channels.matrix這種外掛欄位不在頂層報錯,但也不校驗。校驗下放到各通道外掛自帶的ChannelConfigSchema,沒註冊 schema 的通道就是 unvalidated。 - legacy ACP bindings 報錯:
addLegacyChannelAcpBindingIssues(zod-schema.channels-config.ts:20)遞迴掃描bindings.acp,發現就ctx.addIssue,提示「Legacy channel-local ACP bindings were removed; use top-levelbindings[]entries」。舊設定升級會被這裡擋住。 - secret ref 校驗嚴格:env 類
id必須/^[A-Z][A-Z0-9_]{0,127}$/(zod-schema.core.ts:44),file 類id必須是絕對 JSON pointer。格式不對直接報錯,不會靜默退回字面值。 - allowlist 必須有 allowFrom:
requireOpenAllowFrom(zod-schema.providers-core.ts:409)在dmPolicy: "allowlist"時強制要求allowFrom非空,否則 safeParse 失敗。防止「以為開了白名單其實誰都能 DM」。 - accounts 用 catchall 不是 record:
z.object({}).catchall(accountSchema)(plugins/config-schema.ts:46)比z.record()多一層——能區分「已知欄位」和「動態帳號 key」,未來加defaultAccount這種保留欄位時不會跟帳號 id 衝突。 - enabled=false 不是 configured:
hasMeaningfulChannelConfig(config-presence.ts:47)顯式排除enabled,setup 流程不會把「顯式禁用」誤認為「已設定但未啟動」。 - WhatsApp 走 preprocess:
WhatsAppConfigSchema = z.preprocess(...)(zod-schema.providers-whatsapp.ts:238)在解析前先遷移老設定,不是 superRefine——mutate-before-validate 對歷史相容很關鍵,但除錯時要注意 safeParse 收到的 data 已經是遷移後的。
小結
通道設定層把 22 個通道各自的欄位差異吸收在 ChannelsSchema.passthrough() + 各通道 ConfigSchema 兩級——頂層只管公共欄位和 legacy 偵測,欄位級校驗下放。多帳號模式用 buildCatchallMultiAccountChannelSchema 統一,secret 用 SecretInputSchema 三源 (env / file / exec) 相容字面值,匹配走 direct / normalized / parent / wildcard 五級回退並寫 matchSource 讓下游審計。
怎麼從 通道登錄表 拿到 plugin 後呼叫它的 config.listAccountIds / config.resolveAccount 把這些設定轉成結構化 account 物件,看 通道介面卡。openclaw.json 的整體載入與校驗流程看 openclaw.json 設定,所有 zod schema 的組織方式看 zod schema 體系。