渠道配置
职责
渠道配置 (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这种插件字段不在顶层报错,但也不校验。校验下放到各渠道 plugin 自带的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 体系。