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 :
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 perdueInvariant 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’originesource: "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’autorisationallowBots. Ce type et ce prédicat n’existent pas dans la base de code.allowBotsest 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 danssrc/channels/message/*(withDurableMessageSendContext,createMessageReceiveContext,createLiveMessageState,classifyDurableSendRecoveryState) plutôt que derrière une façadecore.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 relationkind: "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 utilisaitimmediate | after-record | after-durable-send | manualavec un champ de motif d’expiration du délai du Webhook ; cette structure n’a pas été construite. - Les clés de capacité
DurableFinalDeliveryRequirementMapont remplacé l’objetMessageCapabilitiesde 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 deverifyDurableFinalCapabilityProofsplutôt que d’une structure imbriquée de typetext.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.