Adaptadores de canal
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:
- GatewayAdapter —
startAccount/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/logoutAccountgestionan canales como WhatsApp que requieren login por escaneo de QR. - MessageAdapter — cuatro métodos de salida
send.text/send.media/send.payload/send.poll, más la declaración de política ack dereceivey la declaración de capacidad de streaming en vivo (live preview). Esta es la interfaz unificada de envío en dirección outbound. - 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 dejaundefined.
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
types.plugin.ts:66-111— tipo raízChannelPlugin, combina todas las surfaces de adaptador.types.adapters.ts:244-359—ChannelGatewayContextyChannelGatewayAdapter, definenstartAccount/stopAccount/loginWithQrStart, etc.message/types.ts:306-410—ChannelMessageAdapterShapeformado por las cuatro facets send / durableFinal / live / receive.message/adapter.ts:11-32— helperdefineChannelMessageAdapter, por defecto receive va a ackmanual.telegram/channel.ts:992-1106— adaptadorgatewayde Telegram,startAccountmaneja webhook y polling a la vez.slack/channel.ts:756-776—gateway.startAccountde Slack delega amonitorSlackProvider.discord/channel.ts:682-721— Discord arranca constartupDelayMspara mitigar rate limits.whatsapp/channel.ts:345-368—loginWithQrStart/loginWithQrWait/logoutAccountde WhatsApp.
Flujo de datos
El arranque del canal entra por gateway.startAccount(ctx) (telegram/channel.ts:993):
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):
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):
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):
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"enstartAccount(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
stopAccountde Telegram debe llamarreleaseStoppedTelegramPollingLease(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
startupDelayMsantes de arrancar y es interrumpible porabortSignal(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 correropenclaw doctor --fix, un canal roto no afecta a los demás. - channelRuntime es opcional:
ChannelGatewayContext.channelRuntimees un campo opcional, un plugin externo debe comprobarundefinedantes de usarlo (types.adapters.ts:313), si no, con una versión de SDK distinta habrá NPE. - ack por defecto manual:
defineChannelMessageAdapterdeja receive por defecto enmanual(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 declaratrueencapabilitiesactivan 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.