Messages and delivery
Refactorización del ciclo de vida de los mensajes
Por qué se realizó esta refactorización
La pila de canales surgió de varias correcciones locales: asistentes de entrada separados por
nivel de madurez (runtime.channel.inbound.run para adaptadores sencillos,
runtime.channel.inbound.runPreparedReply para los avanzados), asistentes heredados de envío de respuestas
(dispatchInboundReplyWithBase, recordInboundSessionAndDispatchReply),
streaming de vistas previas específico de cada canal y durabilidad de la entrega final añadida
a las rutas existentes de cargas útiles de respuesta. Esa estructura generó demasiados conceptos públicos y
demasiados lugares donde la semántica de entrega podía divergir.
La brecha de fiabilidad que obligó al rediseño:
Actualización de sondeo de Telegram confirmada -> existe el texto final del asistente -> el proceso se reinicia antes de que sendMessage se complete correctamente -> se pierde la respuesta finalInvariante objetivo: una vez que el núcleo decide que debe existir un mensaje saliente visible, la intención de envío debe ser duradera antes de intentar la llamada a la plataforma, y el recibo de la plataforma debe confirmarse después del éxito. Esto proporciona una recuperación de al menos una vez de forma predeterminada. El comportamiento de exactamente una vez solo existe cuando un adaptador demuestra idempotencia nativa o coteja un intento de estado desconocido tras el envío con el estado de la plataforma antes de repetirlo.
Lo que se publicó
El dominio interno se encuentra en src/channels/message/*:
| Archivo | Responsabilidad |
|---|---|
types.ts |
Contratos de tipos de adaptador, contexto de envío, recibo e intención duradera |
send.ts |
withDurableMessageSendContext / sendDurableMessageBatch: el contexto de envío duradero |
receive.ts |
createMessageReceiveContext: máquina de estados de la política de confirmación de entrada |
live.ts |
Estado de vista previa en vivo y lógica para finalizar en el mismo lugar o recurrir a una alternativa |
state.ts |
classifyDurableSendRecoveryState: clasificación de recuperación tras una interrupción |
receipt.ts |
Normaliza los resultados de envío de la plataforma en MessageReceipt |
capabilities.ts |
Deriva de una carga útil las capacidades necesarias para una finalización duradera |
contracts.ts |
Verificación mediante prueba de contrato de las capacidades declaradas por el adaptador |
adapter.ts |
defineChannelMessageAdapter |
outbound-bridge.ts |
createChannelMessageAdapterFromOutbound: encapsula las funciones heredadas sendText/sendMedia/sendPayload/sendPoll |
ingress-queue.ts |
createChannelIngressQueue: cola duradera de eventos de entrada |
durable-receive.ts |
createDurableInboundReceiveJournal: registro de aceptación/pendiente/finalización/liberación para la desduplicación de entradas |
inbound-reply-dispatch.ts |
dispatchChannelInboundReply y encapsuladores con nombres heredados |
reply-pipeline.ts |
createChannelReplyPipeline, prefijo de respuesta y asistentes de devolución de llamada de escritura |
Superficie pública: openclaw/plugin-sdk/channel-outbound (asistentes de envío/recibo/durabilidad/en vivo/Pipeline de respuestas)
y openclaw/plugin-sdk/channel-inbound (contexto de entrada, runChannelInboundEvent,
dispatchChannelInboundReply). Consulte esas páginas para ver ejemplos de adaptadores, los nombres
de tipos actuales y las notas de migración: son la fuente de referencia de la forma de la API,
no los bocetos siguientes.
Contexto de envío
withDurableMessageSendContext proporciona al código del canal los pasos render, previewUpdate,
send, edit, delete, commit y fail en torno a un mensaje
saliente. sendDurableMessageBatch es el encapsulador para el caso habitual: renderizar, enviar
y luego confirmar con sent/suppressed o marcar como fallido si se produce un error.
sendDurableMessageBatch devuelve un resultado discriminado:
| Estado | Significado |
|---|---|
sent |
Se entregó al menos un mensaje visible de la plataforma |
suppressed |
Ningún mensaje de la plataforma debe considerarse ausente (cancelado por un hook, simulación, etc.) |
partial_failed |
Se entregó al menos un mensaje antes de que fallara una carga útil o un efecto secundario posterior |
failed |
No se produjo ningún recibo de la plataforma |
La durabilidad es required, best_effort o disabled
(MessageDurabilityPolicy en src/channels/message/types.ts). required
aplica un cierre seguro cuando no se puede escribir la intención duradera; best_effort pasa
a un envío directo cuando la persistencia no está disponible; disabled conserva el
comportamiento de envío directo anterior a la refactorización. Los asistentes de compatibilidad heredados usan
disabled de forma predeterminada y no deducen required solo porque un canal tenga un adaptador
de salida genérico.
El límite que sigue siendo peligroso se encuentra después de que la llamada a la plataforma se complete correctamente y antes
de que se confirme el recibo. Si el proceso termina en ese punto, el núcleo no puede saber si el
mensaje de la plataforma existe, a menos que el adaptador declare reconcileUnknownSend.
Ese hook clasifica un envío interrumpido como sent, not_sent o
unresolved; solo not_sent permite repetirlo. Los canales sin cotejo
recurren al estado unknown_after_send (src/channels/message/state.ts,
src/infra/outbound/delivery-queue-recovery.ts) y pueden optar por repetir el envío
al menos una vez solo si los mensajes visibles duplicados constituyen una contrapartida aceptable y documentada
para ese canal.
Contexto de recepción
createMessageReceiveContext realiza un seguimiento del estado de confirmación/rechazo de cada evento de entrada con una
operación ack() idempotente y una operación nack(error) explícita. La política de confirmación
(ChannelMessageReceiveAckPolicy) es una de las siguientes:
| Política | Cuándo confirma |
|---|---|
after_receive_record |
El núcleo ha conservado suficientes metadatos de entrada para desduplicar o enrutar una nueva entrega |
after_agent_dispatch |
Se ha enviado la ejecución del agente |
after_durable_send |
Se ha confirmado el envío saliente duradero de este turno |
manual |
El llamador controla explícitamente el momento de la confirmación (valor predeterminado para los adaptadores que no declaran una política) |
El sondeo de Telegram usa esto para conservar una marca de agua segura de actualizaciones completadas
(safeCompletedUpdateId en extensions/telegram/src/bot-update-tracker.ts):
grammY sigue observando cada actualización cuando entra en la cadena de middleware, pero
OpenClaw solo hace avanzar la marca de agua de reinicio conservada más allá de las actualizaciones que
terminaron su envío, por lo que las actualizaciones fallidas o aún pendientes se repiten tras un reinicio.
El desplazamiento getUpdates ascendente de Telegram sigue siendo responsabilidad de grammY; no se ha creado
una fuente de sondeo completamente duradera que controle las nuevas entregas a nivel de plataforma más allá de esta
marca de agua (consulte Preguntas pendientes).
Vista previa en vivo
src/channels/message/live.ts modela la vista previa, la edición y la finalización como un único ciclo de vida:
createLiveMessageState, markLiveMessagePreviewUpdated,
markLiveMessageFinalized, markLiveMessageCancelled y
deliverFinalizableLivePreviewAdapter (crear una edición final a partir de un borrador, aplicarla
y recurrir a un envío normal cuando la edición no sea posible o falle).
LiveMessageState.phase es idle | previewing | finalizing | finalized | cancelled; canFinalizeInPlace determina si una vista previa puede convertirse en el mensaje
final mediante una edición en lugar de un nuevo envío.
Recibos duraderos
MessageReceipt (src/channels/message/types.ts) normaliza uno o varios
identificadores de mensajes de la plataforma procedentes de un único envío lógico en platformMessageIds, además de
parts por parte (tipo, índice, identificador de hilo, identificador de respuesta). Se conserva un identificador principal
para los hilos y las ediciones posteriores. Esto permite que las entregas de varias partes (texto
y contenido multimedia, texto fragmentado, alternativa de tarjeta) puedan repetirse y desduplicarse después
de un reinicio.
Reducción del SDK público
La refactorización incorporó o dejó obsoletos: reply-runtime, reply-dispatch-runtime,
reply-reference, reply-chunking, los asistentes reply-payload expuestos como API
pública, inbound-reply-dispatch, channel-reply-pipeline y la mayoría de los usos públicos
de la antigua fachada de salida. src/plugin-sdk/channel-message.ts ahora es un
módulo de reexportación @deprecated que apunta a channel-outbound /
channel-inbound; se eliminaron los alias de tiempo de ejecución channel.turn y la antigua
página de documentación /plugins/sdk-channel-turn redirige a
API de entrada de canales. El nuevo código de plugins debe
usar directamente channel-outbound y channel-inbound.
En qué se desvió la implementación del diseño original
El boceto de diseño siguiente nunca se publicó literalmente como se describe. Se conserva como registro para mantener la precisión histórica; no considere estos nombres de tipos como parte de la API actual.
- No existen
MessageOriginnishouldDropOpenClawEcho. El plan original requería una etiqueta de origensource: "openclaw"en los mensajes de error del Gateway, además de un predicado compartido que descartara los ecos etiquetados generados por bots en salas compartidas antes de la autorizaciónallowBots. Ese tipo y ese predicado no existen en el código base.allowBotssí es una clave de configuración real por canal (Slack, Discord, Google Chat y otros), pero nunca se creó el mecanismo de etiquetado de origen que debía protegerla. La supresión de ecos de errores del Gateway en salas con bots habilitados sigue siendo una carencia pendiente, no una garantía publicada. - No existe un espacio de nombres
core.messages.receive/send/live/stateunificado. Las funciones publicadas se encuentran directamente ensrc/channels/message/*(withDurableMessageSendContext,createMessageReceiveContext,createLiveMessageState,classifyDurableSendRecoveryState) en lugar de estar detrás de una fachadacore.messages.*. - No existe un tipo de mensaje normalizado genérico
ChannelMessage/MessageTarget/MessageRelation. El núcleo sigue pasando cargas útiles de respuesta concretas (ReplyPayload) y contextos específicos del canal a través de los adaptadores de envío, en lugar de una única forma de mensaje independiente de la plataforma con una relaciónkind: "reply" | "followup" | "broadcast" | "system". - Los nombres de las políticas de confirmación difieren de los del boceto. Publicado:
after_receive_record | after_agent_dispatch | after_durable_send | manual. El boceto original usabaimmediate | after-record | after-durable-send | manualcon un campo de motivo del tiempo de espera del Webhook; esa forma no se creó. - Las claves de capacidad
DurableFinalDeliveryRequirementMapsustituyeron al objetoMessageCapabilitiesdel boceto. Las capacidades son indicadores booleanos planos (text,media,poll,payload,silent,replyTo,thread,nativeQuote,messageSendingHooks,batch,reconcileUnknownSend,afterSendSuccess,afterCommit) verificados medianteverifyDurableFinalCapabilityProofs, en lugar de una estructura anidada al estilo detext.chunking/attachments.voice.
Riesgos concretos de la migración (aún pertinentes)
Estos efectos secundarios específicos de cada canal son anteriores a la refactorización y deben seguir funcionando mediante las nuevas rutas de envío. No son hipotéticos: todos están implementados y son esenciales actualmente.
- iMessage (
extensions/imessage/src/monitor/echo-cache.ts,persisted-echo-cache.ts): el monitor registra los mensajes enviados en una caché de eco después de un envío correcto. Los envíos finales duraderos deben seguir llenando esa caché; de lo contrario, OpenClaw puede volver a ingerir sus propias respuestas como mensajes entrantes del usuario. - Tlon (
extensions/tlon/src/monitor/index.ts): añade una firma opcional del modelo y registra los hilos en los que se ha participado después de las respuestas de grupo. La entrega duradera no debe omitir esos efectos. - Discord y otros despachadores preparados ya gestionan la entrega directa y el comportamiento de las vistas previas. Un canal no es duradero de extremo a extremo hasta que su despachador preparado enruta explícitamente los mensajes finales mediante el contexto de envío; no se debe asumir que el adaptador genérico por sí solo ofrece cobertura.
- La entrega alternativa silenciosa de Telegram debe entregar todo el arreglo de cargas útiles proyectadas, no solo la primera carga útil, después de la fragmentación o proyección alternativa.
- LINE, Zalo, Nostr y rutas auxiliares similares pueden incluir la gestión de tokens de respuesta, el uso de proxies para contenido multimedia, cachés de mensajes enviados o destinos disponibles únicamente mediante callbacks. Permanecen bajo la entrega gestionada por el canal hasta que el adaptador de envío represente esas semánticas y estén cubiertas por pruebas.
- Los auxiliares de mensajes directos pueden tener un callback de respuesta que sea el único destino de transporte correcto. La salida genérica no debe inferir un destino a partir de campos sin procesar de la plataforma y omitir ese callback.
Clasificación de fallos
Los adaptadores clasifican los fallos de transporte en categorías cerradas
del tipo DeliveryFailureKind (transitorio, límite de frecuencia, autenticación,
permiso, no encontrado, carga útil no válida, conflicto, cancelado, desconocido).
Política del núcleo:
- Reintentar los fallos transitorios y por límite de frecuencia.
- No reintentar los fallos por carga útil no válida, salvo que exista una alternativa de renderizado.
- No reintentar los fallos de autenticación o permisos hasta que cambie la configuración.
- Cuando no se encuentre el elemento, permitir que la finalización en vivo pase de la edición a un nuevo envío cuando el canal declare que es seguro.
- En caso de conflicto, utilizar el estado del recibo o de idempotencia para determinar si el mensaje ya existe.
- Cualquier error que se produzca después de que la llamada a la plataforma
pueda haber tenido éxito, pero antes de confirmar el recibo, se convierte en
unknown_after_send, salvo que el adaptador demuestre que la operación de la plataforma no se realizó.
Preguntas abiertas
- Si Telegram debería sustituir en algún momento el ejecutor de sondeo de grammY
(
1.43.0) por una fuente de sondeo completamente duradera que controle la reentrega a nivel de plataforma, y no solo la marca de agua de reinicio persistente de OpenClaw (safeCompletedUpdateId). - Si el estado de la vista previa en vivo debería residir en el mismo registro que la intención del envío final o en un almacén hermano de estado en vivo.
- Si la supresión de eco por fallos del Gateway en salas compartidas con bots habilitados necesita el mecanismo de etiquetado de origen previsto inicialmente, un contrato más sencillo por canal o queda fuera del alcance.
- Qué canales ofrecen compatibilidad nativa con el origen o los metadatos para suprimir el eco entre bots y cuáles necesitan un registro persistente de mensajes salientes.