Registro de canal
Responsabilidad
El registro de canal (channel registry) es el layer que unifica «22 canales de chat + cualquier canal de plugin de terceros» en un diccionario consultable. Solo expone cuatro cosas: listar los ids de canal registrados, normalizar (normalize) los alias (alias) que el usuario escribe al id canónico, buscar una entrada de plugin de canal (channel plugin) por id o alias, y generar una línea corta de introducción para las interfaces de setup / status.
El registro en sí no carga la implementación runtime del canal — solo sostiene los metadatos (metadata) y los índices de búsqueda. Quien de verdad llama startAccount, sendMessage y similares es el adaptador de canal; el registro solo se ocupa de «dime qué entrada de plugin corresponde a este id telegram, y qué alias tiene».
Motivación de diseño
¿Por qué separar un layer de registro en lugar de hacer import { telegramPlugin } from "..." directamente? Tres razones prácticas:
Primera, el número de canales es fijo pero hay alias. telegram en la config del usuario puede aparecer como tg, Telegram, TELEGRAM; el registro debe normalizarlos todos al mismo id canónico, si no, todos los maps indexados por id downstream tendrían que repetir el manejo de case y alias.
Segunda, los canales tienen dos layers, bundled y loaded.bundled son los metadatos que vienen con el código generado, loaded son los que se cargan en runtime desde un paquete npm de plugin. loaded tiene prioridad sobre bundled, así al instalar el paquete de plugin de telegram se puede pin o override la versión bundled, pero sin instalar también se puede usar el fallback bundled. El registro debe mergear ambos layers en una sola vista de consulta.
Tercera, registro en caliente (hot registration) de plugins: los canales registrados tras el snapshot de arranque también deben poder consultarse (tras arreglar issues como #94127). El registro no puede construirse una sola vez al arranque, pero tampoco puede recomputar en cada consulta — por eso se añade un número de versión de snapshot (version) para invalidar la caché.
Archivos clave
channels/registry.ts:22-77— facade pública,normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine.registry-lookup.ts:42-100— vista de lookup con caché de versión,buildRegisteredChannelPluginLookupes la ruta caliente.channels/ids.ts:21-93— fuente de los 22 ids y alias de canal bundled, derivada de generated metadata.channels/plugins/registry.ts:20-65— facade runtime, superpone el fallback bundled.registry-loaded.ts— estado real de los plugins de canal loaded, llamado porregistry.ts.registry-loader.ts:17-43— loader de valor perezoso genérico, resuelve cualquier surface de plugin desde un id de canal.plugins/bundled.ts:796-900— loader de canal bundled, solo warn sin lanzar en fallo.plugins/catalog.ts:463-532— builder del catálogo UI, fusiona bundled / oficial / externo.bundled-channel-config-metadata.generated.ts— metadatos autogenerados de los 22 canales, con schema, alias, order.
Flujo de datos
Los 22 ids de canal bundled vienen de generated metadata. ids.ts los filtra y ordena al arranque (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" }),
);
}Los 22 ids reales son: clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser.
La normalización hace fallback de dos niveles, «tabla de alias → catálogo runtime» (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);
}Aquí primero se prueba con CHAT_CHANNEL_ALIASES generado en tiempo de compilación para los alias comunes, si no acierta se va al fallback listRuntimeBundledChatChannelEntries — este último lee bundled-channel-catalog-read.ts y cubre los metadatos bundled que no están generados en compilación pero sí registrados dinámicamente en runtime.
El lookup runtime lo hace registry-lookup.ts con caché de snapshot + versión (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;
}
// ... reconstruye índices byKey / byId
}Notar que la condición de reconstrucción usa cuatro igualdades de referencia más un version. version es un entero que el plugin registry incrementa cada vez que se registra un plugin nuevo en caliente, lo que invalida la caché automáticamente — evita recomputar el Map byKey en cada consulta al registry.
Al llenar el índice se aplica first writer wins (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);
}
}Esta regla garantiza que el id canónico siempre apunte a la primera entrada registrada, y que un alias registrado más tarde no pueda sobrescribirlo — clave para escenarios de hot-plug, evita que un plugin instalado después sobrescriba por accidente un canal bundled.
Para buscar el cuerpo del plugin de canal se llama getChannelPlugin, primero loaded y luego fallback 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);
}Cuando la carga bundled falla, solo warn sin lanzar (plugins/bundled.ts:679) — log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`)y cacheanull. La siguiente consulta al mismo id devuelve undefined directamente, sin reintentar la carga — es el margen de tolerancia para «dependencia runtime ausente», y describeBundledChannelLoadErrorsugiere al usuario correropenclaw doctor --fix`.
La cadena completa de lookup:
Límites y fallos
- Invalidación de caché por version:
buildRegisteredChannelPluginLookupobtieneversionvíagetActivePluginChannelRegistrySnapshotFromState. Si el plugin registry incrementa version pero se olvida avisar al channel state, los canales registrados en caliente no se encontrarán. - First writer wins: alias por orden de llegada. Si dos plugins registran el mismo id, solo el primero cuenta; el segundo se ignora silenciosamente — así, conflictos de nombres de id no lanzan error, solo «parece instalado pero no entra en vigor».
- Carga bundled fallida solo warn: errores de missing module sugieren
openclaw doctor --fix(bundled.ts:318), pero no bloquean el arranque. Es para que un canal roto no afecte a los otros 21. - Fallback bundled solo para los 22 canales builtin: los canales de plugin de terceros no entran en generated metadata, si no hay loaded,
getBundledChannelPlugindevuelve undefined directamente — sin fallback. - Fallback de catálogo runtime:
listRuntimeBundledChatChannelEntriescachea una vez con??=(ids.ts:61), la primera llamada lee el archivo de catálogo y luego solo usa memoria. Si el catálogo se reemplaza en runtime, hay que reiniciar el proceso para notarlo. - Momento de generación:
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAlo generascripts/generate-bundled-channel-config-metadata.tsen build time, añadir un canal nuevo exige rerun el script de generación, si no,CHAT_CHANNEL_ID_SETno incluye el nuevo id.
Resumen
El registro solo hace cuatro cosas — listar, normalizar, buscar, presentar — sin tocar el runtime del canal. Cubre 22 canales bundled + cualquier canal de plugin de terceros con dos niveles de fallback (generated metadata + runtime catalog), usa caché con version de snapshot para evitar recomputar índices en cada consulta, y aplica first-writer-wins para garantizar que el hot-register no sobrescriba ids canónicos existentes.
Dónde se invocan de verdad las capacidades del canal: Adaptadores de canal. Cómo se configuran estos canales en openclaw.json: Configuración de canal. El mecanismo integral del sistema de plugins: Plugins. Cómo el canal inyecta mensajes en el núcleo del gateway al bucle principal del agent y los reenvía por el adaptador, se cubre en la siguiente página.