Skip to content

Adaptadores de canal

源码版本v2026.6.11

Responsabilidad

Un adaptador de canal (channel adapter) es la capa pegamento que traduce «22 plataformas de chat con protocolos distintos» al flujo de eventos unificado interno de OpenClaw. Cada canal implementa un conjunto de interfaces de adaptador; el gateway solo habla con ese conjunto de interfaces y no le importa si debajo hay Telegram polling, Slack Socket Mode o Discord Gateway.

Los adaptadores se dividen en tres grupos:

  1. GatewayAdapterstartAccount / stopAccount, responsable de levantar la conexión larga de una cuenta (polling / webhook / WebSocket) y de alimentar los eventos crudos de la plataforma al bucle principal del agent; loginWithQrStart / loginWithQrWait / logoutAccount gestionan canales como WhatsApp que requieren login por escaneo de QR.
  2. MessageAdapter — cuatro métodos de salida send.text / send.media / send.payload / send.poll, más la declaración de política ack de receive y la declaración de capacidad de streaming en vivo (live preview). Esta es la interfaz unificada de envío en dirección outbound.
  3. Adaptadores de capacidad específica (fine-grained)config / setup / pairing / security / groups / mentions / outbound / status / gateway / auth / approval / elevated / commands / lifecycle / secrets / allowlist / doctor / bindings / conversationBindings / streaming / threading / message / messaging / agentPrompt / directory / resolver / actions / heartbeat, cada uno opcional — el canal implementa por capacidad, y lo que no se implementa se deja undefined.

Motivación de diseño

Separar gateway / message / un montón de adaptadores pequeños se debe a que las diferencias de capacidad entre canales son grandes:

Telegram soporta tanto webhook como long polling, WhatsApp exige escanear QR, Slack va por Socket Mode (WebSocket), Discord va por Gateway (WebSocket), iMessage va por un puente local AppleScript. Si se mete todo en una interfaz Channel unificada, o se llena de campos opcionales, o cada canal implementa un montón de métodos vacíos.

En dirección outbound, algunos canales soportan replyToId, otros threadId, otros nativeQuote, otros streaming preview (draft preview). Estas capacidades son declarativas — el adaptador declara nativeStreaming: true en capabilities, el código core solo lo usa si lo ve declarado; si no, va por la ruta de degradación.

El ChannelGatewayContext que recibe startAccount trae abortSignal, channelRuntime, setStatus, getStatus — son capacidades que el gateway inyecta al canal, no cosas que el canal se fabrique. channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher es la entrada por la que el gateway deja al canal alimentar eventos inbound al bucle principal del agent.

Por tanto el diseño del layer de adaptadores es: interfaces finas + todas opcionales + capacidades declarativas. El canal implementa por necesidad; el código core elige estrategia según las capacidades declaradas.

Archivos clave

Flujo de datos

El arranque del canal entra por gateway.startAccount(ctx) (telegram/channel.ts:993):

typescript
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    // ... probe token, resolve bot info
    return startTelegramMonitor({
      token,
      accountId: account.accountId,
      cfg: ctx.cfg,
      runtime: ctx.runtime,
      channelRuntime: ctx.channelRuntime,
      abortSignal: ctx.abortSignal,
      useWebhook: Boolean(account.config.webhookUrl),
      webhookUrl: account.config.webhookUrl,
      webhookSecret: account.config.webhookSecret,
      // ...
    });
  },
  stopAccount: async ({ account, accountId, log }) => {
    const released = await releaseStoppedTelegramPollingLease({ token, accountId });
    if (released) {
      log?.info?.(`[${accountId}] released stopped Telegram polling lease`);
    }
  },
}

Notas: ctx.account es la cuenta estructurada parseada por Configuración de canal; ctx.channelRuntime es el paquete de capacidades inyectado por el gateway, con ocho submódulos: reply / routing / text / session / media / commands / groups / pairing; abortSignal es la señal de ciclo de vida del gateway, al parar el gateway la conexión larga debe cortarse inmediatamente.

El startAccount de Slack (slack/channel.ts:757) es más fino, delega directamente a monitorSlackProvider: los dos tokens botToken + appToken se pasan tras trim (appToken es exclusivo de Socket Mode, botToken es el de la bot API normal), el resto de campos es similar a Telegram — channelRuntime / abortSignal / setStatus / getStatus. Slack Socket Mode es una conexión WebSocket larga, implementada dentro de monitorSlackProvider; setStatus / getStatus dejan al monitor reflejar el estado de ejecución de la cuenta en el snapshot del registro de canal.

El arranque de Discord añade una protección contra rate-limit (discord/channel.ts:690):

typescript
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
  ctx.log?.info(
    `[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
  );
  try {
    await sleepWithAbort(startupDelayMs, ctx.abortSignal);
  } catch {
    return;
  }
}

sleepWithAbort deja que la espera también responda a abortSignal — si el gateway se para, aborta el arranque en lugar de esperar tontamente al final antes de salir. El estado del token de Discord también debe validarse primero; configured_unavailable lanza error (discord/channel.ts:685), evitando arrancar con un SecretRef no resuelto.

El login por QR de WhatsApp es en dos fases (whatsapp/channel.ts:345): loginWithQrStart levanta el web login y devuelve el código QR, loginWithQrWait hace polling esperando a que el usuario termine de escanear, logoutAccount llama a logoutWeb para limpiar authDir. Los dos métodos coordinan para que la interfaz de setup renderice el QR y bloquee esperando el resultado, en lugar de meter todo el login en una promesa larguísima.

La dirección outbound va por MessageAdapter (message/types.ts:306):

typescript
export type ChannelMessageSendAdapter<
  TConfig = OpenClawConfig,
  TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
  text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
  media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
  payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
  poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
  lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};

Cuatro métodos para cuatro payloads outbound: text texto plano, media multimedia, payload tarjetas ricas, poll votaciones. lifecycle es un hook: beforeSend / afterSendSuccess / afterSendFailure / afterCommit, deja al canal meter su lógica alrededor del envío (por ejemplo, Slack actualiza el contador de replies del parent message).

La dirección inbound (recepción) va por políticas ack (message/types.ts:374):

typescript
export type ChannelMessageReceiveAckPolicy =
  | "after_receive_record"
  | "after_agent_dispatch"
  | "after_durable_send"
  | "manual";

Cuatro estrategias controlan «cuándo ack a la plataforma que este mensaje se ha recibido» — after_receive_record es la más temprana, justo tras persistir; after_agent_dispatch tras que el agent reciba el evento; after_durable_send tras que la respuesta se entregue de forma durable; manual el canal lo controla. defineChannelMessageAdapter da manual por defecto (message/adapter.ts:12), porque la mayoría de los canales necesita decidir su propio momento de ack.

La cadena completa inbound → outbound:

Límites y fallos

  • SecretRef no resuelto lanza al arrancar: Discord comprueba tokenStatus === "configured_unavailable" en startAccount (discord/channel.ts:685), lanza error en lugar de ir a conectar con token vacío, evitando que los logs filtren el estado límite de «token es cadena vacía».
  • Release del polling lease: el stopAccount de Telegram debe llamar releaseStoppedTelegramPollingLease (telegram/channel.ts:1094), si no, al reiniciar el polling lease de la ronda anterior sigue activo y causa conflicto de token.
  • Rate limit con delay proactivo: Discord añade un startupDelayMs antes de arrancar y es interrumpible por abortSignal (discord/channel.ts:696). Convierte «prevenir rate limit de arranque concurrente» en un sleep explícito en lugar de un retry por fallo.
  • Fallo de carga bundled solo warn: describeBundledChannelLoadError (bundled.ts:318) sugiere correr openclaw doctor --fix, un canal roto no afecta a los demás.
  • channelRuntime es opcional: ChannelGatewayContext.channelRuntime es un campo opcional, un plugin externo debe comprobar undefined antes de usarlo (types.adapters.ts:313), si no, con una versión de SDK distinta habrá NPE.
  • ack por defecto manual: defineChannelMessageAdapter deja receive por defecto en manual (message/adapter.ts:12), cuando el canal no declara su política de ack el código core no hace ack automático por él — evita «creer que el mensaje se procesó pero se perdió».
  • Degradación declarativa por capabilities: durableFinalDeliveryCapabilities (message/types.ts:17) lista 12 capacidades, solo las que el canal declara true en capabilities activan la ruta de entrega confiable, las no declaradas van por degradación, sin lanzar error.

Resumen

El layer de adaptadores encapsula las diferencias de 22 canales detrás de un conjunto de interfaces finas, todas opcionales, con capacidades declarativas. GatewayAdapter gestiona arranque/parada y login, MessageAdapter gestiona envío outbound y ack inbound, y los adaptadores finos gestionan cada uno una faceta de capacidad. El gateway inyecta capacidades vía channelRuntime, controla el ciclo de vida vía abortSignal, y elige estrategia de forma declarativa vía capabilities.

Cómo se obtiene la entrada del plugin desde el registro de canal para luego llamar a su startAccount, y cómo los eventos del agent llegan al MessageAdapter para reenviarlos a la plataforma, se cubre en Difusión de chat. Cómo se configuran token, allowFrom y accounts para cada canal en openclaw.json se cubre en Configuración de canal.