Messages and delivery

Refactorisation du cycle de vie des messages

Pourquoi cette refactorisation a eu lieu

La pile des canaux s’est développée à partir de plusieurs correctifs locaux : des assistants entrants distincts pour chaque niveau de maturité (runtime.channel.inbound.run pour les adaptateurs simples, runtime.channel.inbound.runPreparedReply pour les plus riches), des assistants historiques de distribution des réponses (dispatchInboundReplyWithBase, recordInboundSessionAndDispatchReply), la diffusion en continu des aperçus propre à chaque canal, et la durabilité de la livraison finale ajoutée aux chemins existants des charges utiles de réponse. Cette structure a produit trop de concepts publics et trop d’endroits où la sémantique de livraison pouvait diverger.

La lacune de fiabilité qui a imposé la nouvelle conception :

text
Mise à jour de l’interrogation Telegram acquittée  -> le texte final de l’assistant existe  -> le processus redémarre avant la réussite de sendMessage  -> la réponse finale est perdue

Invariant cible : dès que le cœur décide qu’un message sortant visible doit exister, l’intention d’envoi doit être durable avant toute tentative d’appel à la plateforme, et le reçu de la plateforme doit être validé après la réussite. Cela assure par défaut une récupération avec livraison au moins une fois. Un comportement avec livraison exactement une fois n’existe que lorsqu’un adaptateur démontre une idempotence native ou rapproche une tentative dont l’issue est inconnue après l’envoi avec l’état de la plateforme avant de la rejouer.

Ce qui a été livré

Le domaine interne se trouve dans src/channels/message/* :

Fichier Responsabilité
types.ts Contrats de types des adaptateurs, du contexte d’envoi, des reçus et des intentions durables
send.ts withDurableMessageSendContext / sendDurableMessageBatch — le contexte d’envoi durable
receive.ts createMessageReceiveContext — automate d’état de la politique d’acquittement entrant
live.ts État de l’aperçu en direct et logique de finalisation sur place ou de repli
state.ts classifyDurableSendRecoveryState — classification de la récupération après interruption
receipt.ts Normalise les résultats d’envoi de la plateforme en MessageReceipt
capabilities.ts Déduit les capacités finales durables requises à partir d’une charge utile
contracts.ts Vérification des preuves contractuelles pour les capacités déclarées des adaptateurs
adapter.ts defineChannelMessageAdapter
outbound-bridge.ts createChannelMessageAdapterFromOutbound — encapsule les fonctions historiques sendText/sendMedia/sendPayload/sendPoll
ingress-queue.ts createChannelIngressQueue — file d’événements entrants durable
durable-receive.ts createDurableInboundReceiveJournal — journal d’acceptation/attente/achèvement/libération pour la déduplication entrante
inbound-reply-dispatch.ts dispatchChannelInboundReply et enveloppes aux noms historiques
reply-pipeline.ts createChannelReplyPipeline, assistants de préfixe de réponse et de rappel de saisie

Surface publique : openclaw/plugin-sdk/channel-outbound (assistants d’envoi, de reçu, de durabilité, de direct et de pipeline de réponse) et openclaw/plugin-sdk/channel-inbound (contexte entrant, runChannelInboundEvent, dispatchChannelInboundReply). Consultez ces pages pour obtenir des exemples d’adaptateurs, les noms de types actuels et les notes de migration : elles constituent la source de référence pour la structure de l’API, et non les ébauches ci-dessous.

Contexte d’envoi

withDurableMessageSendContext fournit au code du canal les étapes render, previewUpdate, send, edit, delete, commit et fail autour d’un message sortant. sendDurableMessageBatch est l’enveloppe destinée au cas courant : effectuer le rendu, envoyer, puis valider en cas de résultat sent/suppressed, ou échouer en cas d’erreur.

sendDurableMessageBatch renvoie un résultat discriminé :

État Signification
sent Au moins un message visible a été livré sur la plateforme
suppressed Aucun message de plateforme ne doit être considéré comme manquant (annulation par un hook, exécution à blanc, etc.)
partial_failed Au moins un message a été livré avant l’échec d’une charge utile ou d’un effet secondaire ultérieur
failed Aucun reçu de plateforme n’a été produit

La durabilité prend l’une des valeurs required, best_effort ou disabled (MessageDurabilityPolicy dans src/channels/message/types.ts). required échoue de manière fermée lorsque l’intention durable ne peut pas être écrite ; best_effort passe à un envoi direct lorsque la persistance est indisponible ; disabled conserve le comportement d’envoi direct antérieur à la refactorisation. Les assistants de compatibilité historiques utilisent disabled par défaut et ne déduisent pas required du simple fait qu’un canal possède un adaptateur sortant générique.

La limite qui reste dangereuse se situe après la réussite de l’appel à la plateforme et avant la validation du reçu. Si le processus s’arrête à ce moment-là, le cœur ne peut pas savoir si le message existe sur la plateforme, sauf si l’adaptateur déclare reconcileUnknownSend. Ce hook classe un envoi interrompu comme sent, not_sent ou unresolved ; seul not_sent autorise une nouvelle tentative. Les canaux sans rapprochement se replient sur l’état unknown_after_send (src/channels/message/state.ts, src/infra/outbound/delivery-queue-recovery.ts) et ne peuvent choisir une nouvelle tentative avec livraison au moins une fois que si les messages visibles en double constituent un compromis acceptable et documenté pour ce canal.

Contexte de réception

createMessageReceiveContext suit l’état d’acquittement et de non-acquittement de chaque événement entrant au moyen d’un ack() idempotent et d’un nack(error) explicite. La politique d’acquittement (ChannelMessageReceiveAckPolicy) prend l’une des valeurs suivantes :

Politique Moment de l’acquittement
after_receive_record Le cœur a persisté suffisamment de métadonnées entrantes pour dédupliquer ou acheminer une nouvelle livraison
after_agent_dispatch L’exécution de l’agent a été distribuée
after_durable_send L’envoi sortant durable pour cette interaction a été validé
manual L’appelant contrôle explicitement le moment de l’acquittement (valeur par défaut pour les adaptateurs qui ne déclarent aucune politique)

L’interrogation Telegram utilise ce mécanisme pour persister un repère de mise à jour achevée de manière sûre (safeCompletedUpdateId dans extensions/telegram/src/bot-update-tracker.ts) : grammY observe toujours chaque mise à jour lorsqu’elle entre dans la chaîne des intergiciels, mais OpenClaw ne fait progresser le repère de redémarrage persisté au-delà que des mises à jour ayant achevé leur distribution, de sorte que les mises à jour en échec ou toujours en attente sont rejouées après un redémarrage. Le décalage getUpdates en amont de Telegram reste géré par grammY ; aucune source d’interrogation entièrement durable contrôlant la nouvelle livraison au niveau de la plateforme au-delà de ce repère n’a encore été construite (voir Questions ouvertes).

Aperçu en direct

src/channels/message/live.ts modélise l’aperçu, la modification et la finalisation comme un seul cycle de vie : createLiveMessageState, markLiveMessagePreviewUpdated, markLiveMessageFinalized, markLiveMessageCancelled et deliverFinalizableLivePreviewAdapter (construire une modification finale à partir d’un brouillon, l’appliquer et se replier sur un envoi normal lorsque la modification est impossible ou échoue). LiveMessageState.phase vaut idle | previewing | finalizing | finalized | cancelled ; canFinalizeInPlace détermine si un aperçu peut devenir le message final par modification plutôt que par un nouvel envoi.

Reçus durables

MessageReceipt (src/channels/message/types.ts) normalise un ou plusieurs identifiants de message de plateforme issus d’un seul envoi logique dans platformMessageIds, ainsi que des parts par partie (type, index, identifiant de fil, identifiant du message auquel répondre). Un identifiant principal est conservé pour les fils de discussion et les modifications ultérieures. C’est ce qui permet aux livraisons en plusieurs parties (texte avec média, texte segmenté, repli de carte) d’être rejouables et déduplicables après un redémarrage.

Réduction du SDK public

La refactorisation a absorbé ou rendu obsolètes : les assistants reply-runtime, reply-dispatch-runtime, reply-reference, reply-chunking et reply-payload exposés comme API publique, inbound-reply-dispatch, channel-reply-pipeline, ainsi que la plupart des utilisations publiques de outbound-runtime. src/plugin-sdk/channel-message.ts est désormais un module de réexportation @deprecated pointant vers channel-outbound / channel-inbound ; les alias d’exécution channel.turn ont été supprimés et l’ancienne page de documentation /plugins/sdk-channel-turn redirige vers API entrante des canaux. Le nouveau code des plugins doit cibler directement channel-outbound et channel-inbound.

Divergences entre l’implémentation et la conception d’origine

L’ébauche de conception ci-dessous n’a jamais été livrée exactement telle qu’elle était décrite. Elle est conservée pour garantir l’exactitude historique ; ne considérez pas ces noms de types comme ceux de l’API actuelle.

  • Aucun MessageOrigin / shouldDropOpenClawEcho. Le plan d’origine prévoyait une balise d’origine source: "openclaw" sur les messages d’échec du Gateway, ainsi qu’un prédicat partagé supprimant, dans les salons partagés, les échos balisés rédigés par des bots avant l’autorisation allowBots. Ce type et ce prédicat n’existent pas dans la base de code. allowBots est bien une clé de configuration propre à chaque canal (Slack, Discord, Google Chat et d’autres), mais le mécanisme de balisage de l’origine destiné à la protéger n’a jamais été construit. La suppression des échos d’échec du Gateway dans les salons où les bots sont activés reste une lacune ouverte, et non une garantie livrée.
  • Aucun espace de noms unifié core.messages.receive/send/live/state. Les fonctions livrées se trouvent directement dans src/channels/message/* (withDurableMessageSendContext, createMessageReceiveContext, createLiveMessageState, classifyDurableSendRecoveryState) plutôt que derrière une façade core.messages.*.
  • Aucun type de message normalisé générique ChannelMessage / MessageTarget / MessageRelation. Le cœur transmet toujours des charges utiles de réponse concrètes (ReplyPayload) et des contextes propres aux canaux aux adaptateurs d’envoi, plutôt qu’une structure de message indépendante de la plateforme avec une relation kind: "reply" | "followup" | "broadcast" | "system".
  • Les noms des politiques d’acquittement diffèrent de ceux de l’ébauche. Valeurs livrées : after_receive_record | after_agent_dispatch | after_durable_send | manual. L’ébauche d’origine utilisait immediate | after-record | after-durable-send | manual avec un champ de motif d’expiration du délai du Webhook ; cette structure n’a pas été construite.
  • Les clés de capacité DurableFinalDeliveryRequirementMap ont remplacé l’objet MessageCapabilities de l’ébauche. Les capacités sont des indicateurs booléens plats (text, media, poll, payload, silent, replyTo, thread, nativeQuote, messageSendingHooks, batch, reconcileUnknownSend, afterSendSuccess, afterCommit) vérifiés au moyen de verifyDurableFinalCapabilityProofs plutôt que d’une structure imbriquée de type text.chunking / attachments.voice.

Risques concrets liés à la migration (toujours pertinents)

Ces effets secondaires propres aux canaux précèdent la refactorisation et doivent continuer à fonctionner avec les nouveaux chemins d’envoi. Ils ne sont pas hypothétiques : chacun est actuellement implémenté et indispensable.

  • iMessage (extensions/imessage/src/monitor/echo-cache.ts, persisted-echo-cache.ts) : le moniteur enregistre les messages envoyés dans un cache d’écho après un envoi réussi. Les envois finaux durables doivent continuer à alimenter ce cache, sinon OpenClaw peut réingérer ses propres réponses comme messages utilisateur entrants.
  • Tlon (extensions/tlon/src/monitor/index.ts) : ajoute une signature facultative du modèle et enregistre les fils de discussion auxquels OpenClaw a participé après les réponses de groupe. La remise durable ne doit pas contourner ces effets.
  • Discord et les autres répartiteurs préparés gèrent déjà la remise directe et le comportement d’aperçu. Un canal n’est pas durable de bout en bout tant que son répartiteur préparé n’achemine pas explicitement les envois finaux via le contexte d’envoi ; ne supposez pas que l’adaptateur générique suffit à assurer cette prise en charge.
  • La remise de secours silencieuse de Telegram doit transmettre l’intégralité du tableau de charges utiles projetées, et non uniquement la première charge utile, après le découpage ou la projection de secours.
  • LINE, Zalo, Nostr et les chemins d’assistance similaires peuvent gérer des jetons de réponse, le mandatement de médias, des caches de messages envoyés ou des cibles accessibles uniquement par rappel. Ils restent sous la responsabilité du canal jusqu’à ce que ces sémantiques soient représentées par l’adaptateur d’envoi et couvertes par des tests.
  • Les assistants de messages privés directs peuvent disposer d’un rappel de réponse qui constitue l’unique cible de transport correcte. Le mécanisme sortant générique ne doit pas déduire une cible à partir de champs bruts de la plateforme et ignorer ce rappel.

Classification des échecs

Les adaptateurs classent les échecs de transport dans des catégories fermées de type DeliveryFailureKind (temporaire, limite de débit, authentification, autorisation, introuvable, charge utile non valide, conflit, annulé, inconnu). Politique du cœur :

  • Réessayer en cas d’échec temporaire ou de limite de débit.
  • Ne pas réessayer en cas de charge utile non valide, sauf s’il existe une solution de secours pour le rendu.
  • Ne pas réessayer en cas d’échec d’authentification ou d’autorisation tant que la configuration n’a pas changé.
  • En cas d’élément introuvable, permettre à la finalisation en direct de remplacer la modification par un nouvel envoi lorsque le canal déclare cette opération sûre.
  • En cas de conflit, utiliser l’état de l’accusé de réception et de l’idempotence pour déterminer si le message existe déjà.
  • Toute erreur survenant après la réussite potentielle de l’appel à la plateforme, mais avant la validation de l’accusé de réception, devient unknown_after_send, sauf si l’adaptateur prouve que l’opération sur la plateforme n’a pas eu lieu.

Questions ouvertes

  • Déterminer si Telegram doit à terme remplacer le processus d’interrogation de grammY (1.43.0) par une source d’interrogation entièrement durable qui contrôle la nouvelle remise au niveau de la plateforme, et pas seulement le repère de redémarrage persistant d’OpenClaw (safeCompletedUpdateId).
  • Déterminer si l’état de l’aperçu en direct doit résider dans le même enregistrement que l’intention d’envoi final ou dans un stockage voisin dédié à l’état en direct.
  • Déterminer si la suppression des échos en cas d’échec du Gateway dans les salons partagés où les bots sont activés nécessite le mécanisme initialement prévu de balisage de l’origine, un contrat plus simple propre à chaque canal, ou si elle est hors périmètre.
  • Déterminer quels canaux prennent nativement en charge l’origine et les métadonnées pour la suppression des échos entre bots, et lesquels nécessitent un registre sortant persistant.

Voir aussi

Was this useful?
On this page

On this page