Configuración de canal
Responsabilidad
La configuración de canal (channel config) es el layer de parseo y matching del bloque channels.* dentro de openclaw.json. Gestiona tres cosas: parsear el JSON escrito por el usuario a tipos estructurados, hacer matching por id de canal para obtener la configuración de cuenta (account config), y promover los campos sensibles como token / botToken / webhookSecret de cadenas hardcoded a referencias de secret (env / file / exec).
No decide «qué canal debe arrancar» — eso lo hace el registro de canal junto con config-presence.ts. Solo se ocupa de «qué escribió el usuario, si está bien escrito, y por qué clave se matchea a qué configuración de cuenta».
Motivación de diseño
Cuatro razones prácticas.
Primera, los 22 canales tienen campos distintos. Telegram tiene botToken + webhookUrl + pollingStallThresholdMs, Slack tiene botToken + appToken + socketMode, WhatsApp tiene authDir, Discord tiene guilds + channels. Meter todo en un único tipo ChannelsConfig se convertiría en miles de líneas de union types, y tocar un canal requeriría mover tipos core.
Segunda, multi-cuenta. Telegram puede tener varios bots, Slack varios workspaces, cada cuenta con config independiente. Se necesita un patrón unificado accounts: Record<string, AccountConfig> + defaultAccount: string, con los campos de cada canal empujados al nivel account.
Tercera, los secrets no pueden ir hardcoded. botToken: "123456:ABC..." escrito en openclaw.json, si el archivo se filtra el token queda expuesto. SecretInputSchema = z.union([z.string(), SecretRefSchema]) permite escribir tanto la cadena inline legacy como la referencia estructurada {source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN"}.
Cuarta, el matching de config tiene que soportar direct / parent / wildcard. Un mismo canal puede tener varias entradas: un id de cuenta específico una entrada, un canal padre otra, un wildcard otra. Al parsear se hace fallback por prioridad y se marca matchSource para que downstream sepa qué nivel se matcheó.
Archivos clave
channel-config.ts:60-165—resolveChannelEntryMatch/resolveChannelEntryMatchWithFallback, matching direct / parent / wildcard.zod-schema.channels-config.ts:52-69—ChannelsSchematop-level, passthrough tolera campos de plugin.zod-schema.core.ts:33-90—SecretRefSchema/SecretInputSchema, tres fuentes de secret.zod-schema.providers-core.ts:249-409—TelegramAccountSchemaBase/TelegramConfigSchema, ejemplo de schema multi-cuenta.SlackConfigSchema:1054— schema multi-cuenta de Slack.WhatsAppConfigSchema:238— WhatsApp usaz.preprocesspara compat con config vieja.types.channels.ts:127-146— interfazChannelsConfig, campos typed + index signature open-world.plugins/config-schema.ts:27-49— builders compartidosAllowFromEntrySchema/buildCatchallMultiAccountChannelSchema.config-presence.ts:47-66—hasMeaningfulChannelConfig/listExplicitlyDisabledChannelIdsForConfig.
Flujo de datos
El campo top-level channels de openclaw.json pasa por 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>;El top-level solo tiene dos campos strict, defaults y modelByChannel, el resto va por passthrough — channels.telegram / channels.slack / channels.nostr / channels.zalo no se validan a nivel de campo en el schema top-level, la validación de campos se delega al TelegramConfigSchema / SlackConfigSchema / WhatsAppConfigSchema de cada canal. .superRefine solo escanea el campo legacy bindings.acp (ya eliminado), si aparece reporta issue para que el usuario migre al bindings[] top-level.
¿Por qué passthrough? Porque los canales de plugin son open-world — types.channels.ts (types.channels.ts:127) define la interfaz ChannelsConfig con 10 canales core (discord / googlechat / imessage / irc / msteams / signal / slack / telegram / whatsapp, etc.) como campos typed, el resto va por [key: string]: OpenWorldChannelConfig, donde OpenWorldChannelConfig es ReturnType<typeof JSON.parse>, totalmente abierto.
La referencia de secret es la abstracción central del layer de config (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 usa discriminatedUnion("source", ...), tres fuentes: env lee de variable de entorno, id matchea /^[A-Z][A-Z0-9_]{0,127}$/ como TELEGRAM_BOT_TOKEN; file lee de archivo, id es JSON pointer como /providers/openai/apiKey o el modo de valor único value; exec llama a un comando externo para obtener el secret.
SecretInputSchema es z.union([z.string(), SecretRefSchema]) — la cadena inline legacy sigue siendo compatible, pero .register(sensitive) marca el campo como sensible (zod-schema.providers-core.ts:269):
botToken: SecretInputSchema.optional().register(sensitive),Así, openclaw doctor y la salida de logs enmascaran el valor literal de botToken.
El schema de cada canal combina campos comunes + campos específicos; Telegram es el ejemplo típico multi-cuenta (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 es el set de campos a nivel canal, TelegramConfigSchema añade el contenedor multi-cuenta accounts y defaultAccount — buildCatchallMultiAccountChannelSchema (plugins/config-schema.ts:42) es el helper genérico para este patrón:
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) deja que cualquier key bajo accounts se valide por accountSchema — añadir cuentas nuevas no requiere tocar el schema.
Al matchear config se llama resolveChannelEntryMatchWithFallback (channel-config.ts:83), el núcleo es el fallback por prioridad — si direct acierta devuelve { matchKey, matchSource: "direct" }; si no, va por normalized direct (normaliza la key con normalizeKey y reintenta), luego parent, luego normalized parent, y finalmente wildcard. Cada paso escribe matchKey y matchSource en el resultado, y downstream usa applyChannelMatchMeta para copiar ambos al objeto config final, para auditar «de qué key se matcheó esta config».
normalizeKey es una función de normalización opcional, por ejemplo normalizeChannelSlug (channel-config.ts:47) normaliza #general, General, GENERAL ROOM todos a general — el usuario no tiene que escribir nombres exactos en la config.
hasMeaningfulChannelConfig distingue «config con contenido» de «simplemente enabled=false» (config-presence.ts:47): comprueba Object.keys(value).some((key) => key !== "enabled") — enabled por sí solo no cuenta como «hay config», es solo intención operativa, así openclaw status no reporta un canal «explícitamente deshabilitado» como «configurado pero no arrancado».
El flujo completo de parseo y matching:
Límites y fallos
- passthrough tolera campos de plugin:
ChannelsSchema.passthrough()(zod-schema.channels-config.ts:65) deja quechannels.nostr/channels.matrixno lancen error en top-level, pero tampoco valida. La validación cae alChannelConfigSchemadel plugin de cada canal; los canales sin schema registrado quedan unvalidated. - legacy ACP bindings reportan error:
addLegacyChannelAcpBindingIssues(zod-schema.channels-config.ts:20) escanea recursivamentebindings.acp, y si aparece hacectx.addIssuesugiriendo «Legacy channel-local ACP bindings were removed; use top-levelbindings[]entries». Las configs viejas se quedan bloqueadas aquí al actualizar. - Validación estricta de secret ref: el
idde tipo env debe cumplir/^[A-Z][A-Z0-9_]{0,127}$/(zod-schema.core.ts:44), elidde tipo file debe ser un JSON pointer absoluto. Formato incorrecto lanza error, no cae a valor literal. - allowlist exige allowFrom:
requireOpenAllowFrom(zod-schema.providers-core.ts:409) fuerza queallowFromno esté vacío cuandodmPolicy: "allowlist", si no, safeParse falla. Previene «creer que se abrió allowlist pero cualquiera puede hacer DM». - accounts usa catchall no record:
z.object({}).catchall(accountSchema)(plugins/config-schema.ts:46) añade una capa sobrez.record()— distingue «campos conocidos» de «keys dinámicas de cuenta», al añadir campos reservados comodefaultAccounten el futuro no chocan con ids de cuenta. - enabled=false no es configured:
hasMeaningfulChannelConfig(config-presence.ts:47) excluye explícitamenteenabled, el flujo de setup no confunde «explícitamente deshabilitado» con «configurado pero no arrancado». - WhatsApp usa preprocess:
WhatsAppConfigSchema = z.preprocess(...)(zod-schema.providers-whatsapp.ts:238) migra la config vieja antes de parsear, no usa superRefine — mutate-before-validate es clave para compatibilidad con histórico, pero al depurar hay que recordar que el data que ve safeParse ya está migrado.
Resumen
El layer de configuración de canal absorbe las diferencias de campos de 22 canales en ChannelsSchema.passthrough() + ConfigSchema por canal en dos niveles — el top-level solo gestiona campos comunes y detección legacy, la validación de campos se delega. El patrón multi-cuenta usa buildCatchallMultiAccountChannelSchema unificado, los secrets usan SecretInputSchema con tres fuentes (env / file / exec) compatible con valores literales, y el matching recorre cinco niveles de fallback (direct / normalized / parent / normalized-parent / wildcard) escribiendo matchSource para auditoría downstream.
Cómo el registro de canal obtiene el plugin y llama a config.listAccountIds / config.resolveAccount para convertir estas configs en objetos de cuenta estructurados se cubre en Adaptadores de canal. El flujo completo de carga y validación de openclaw.json está en Configuración openclaw.json, y la organización de todos los schemas zod en Sistema de schemas zod.