Skip to content

Registro de canal

源码版本v2026.6.11

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

Flujo de datos

Los 22 ids de canal bundled vienen de generated metadata. ids.ts los filtra y ordena al arranque (ids.ts:21):

typescript
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):

typescript
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):

typescript
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):

typescript
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):

typescript
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: buildRegisteredChannelPluginLookup obtiene version vía getActivePluginChannelRegistrySnapshotFromState. 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, getBundledChannelPlugin devuelve undefined directamente — sin fallback.
  • Fallback de catálogo runtime: listRuntimeBundledChatChannelEntries cachea 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_METADATA lo genera scripts/generate-bundled-channel-config-metadata.ts en build time, añadir un canal nuevo exige rerun el script de generación, si no, CHAT_CHANNEL_ID_SET no 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.