Messages and delivery

Рефакторинг жизненного цикла сообщений

Почему был выполнен этот рефакторинг

Стек каналов вырос из нескольких локальных исправлений: отдельных вспомогательных средств для входящих сообщений каждого уровня зрелости (runtime.channel.inbound.run для простых адаптеров, runtime.channel.inbound.runPreparedReply для расширенных), устаревших вспомогательных средств диспетчеризации ответов (dispatchInboundReplyWithBase, recordInboundSessionAndDispatchReply), потоковой передачи предварительного просмотра для отдельных каналов и обеспечения надёжности окончательной доставки, добавленного поверх существующих путей полезной нагрузки ответов. Такая структура создала слишком много общедоступных концепций и слишком много мест, где семантика доставки могла расходиться.

Пробел в надёжности, из-за которого потребовалось перепроектирование:

text
Обновление, полученное опросом Telegram, подтверждено  -> окончательный текст ассистента существует  -> процесс перезапускается до успешного выполнения sendMessage  -> окончательный ответ потерян

Целевой инвариант: как только ядро решает, что видимое исходящее сообщение должно существовать, намерение отправки должно быть сохранено до попытки вызова платформы, а квитанция платформы должна быть зафиксирована после успешного выполнения. По умолчанию это обеспечивает восстановление с семантикой «не менее одного раза». Семантика «ровно один раз» существует только там, где адаптер подтверждает собственную идемпотентность или сопоставляет попытку с неизвестным результатом после отправки с состоянием платформы перед повторным воспроизведением.

Что было реализовано

Внутренняя предметная область находится в src/channels/message/*:

Файл Назначение
types.ts Контракты типов адаптера, контекста отправки, квитанции и сохраняемого намерения
send.ts withDurableMessageSendContext / sendDurableMessageBatch — сохраняемый контекст отправки
receive.ts createMessageReceiveContext — конечный автомат политики подтверждения входящих сообщений
live.ts Состояние предварительного просмотра в реальном времени и логика завершения на месте либо перехода к резервному варианту
state.ts classifyDurableSendRecoveryState — классификация восстановления после прерывания
receipt.ts Нормализует результаты отправки платформы в MessageReceipt
capabilities.ts Определяет необходимые возможности надёжной окончательной доставки на основе полезной нагрузки
contracts.ts Проверка контрактных подтверждений заявленных возможностей адаптера
adapter.ts defineChannelMessageAdapter
outbound-bridge.ts createChannelMessageAdapterFromOutbound — обёртка устаревших функций sendText/sendMedia/sendPayload/sendPoll
ingress-queue.ts createChannelIngressQueue — сохраняемая очередь входящих событий
durable-receive.ts createDurableInboundReceiveJournal — журнал принятия/ожидания/завершения/освобождения для дедупликации входящих событий
inbound-reply-dispatch.ts dispatchChannelInboundReply и обёртки с устаревшими именами
reply-pipeline.ts createChannelReplyPipeline, вспомогательные средства префикса ответа и обратного вызова индикации набора текста

Общедоступный интерфейс: openclaw/plugin-sdk/channel-outbound (вспомогательные средства отправки/квитанций/надёжности/предварительного просмотра/конвейера ответов) и openclaw/plugin-sdk/channel-inbound (контекст входящих сообщений, runChannelInboundEvent, dispatchChannelInboundReply). Примеры адаптеров, текущие имена типов и примечания по миграции приведены на этих страницах — именно они являются достоверным источником сведений о структуре API, а не приведённые ниже эскизы.

Контекст отправки

withDurableMessageSendContext предоставляет коду канала шаги render, previewUpdate, send, edit, delete, commit и fail для одного исходящего сообщения. sendDurableMessageBatch — обёртка для типового случая: отобразить, отправить, затем зафиксировать при sent/suppressed или завершить с ошибкой.

sendDurableMessageBatch возвращает один дискриминируемый результат:

Состояние Значение
sent Доставлено как минимум одно видимое сообщение платформы
suppressed Ни одно сообщение платформы не должно считаться отсутствующим (отмена хуком, пробный запуск и т. п.)
partial_failed Как минимум одно сообщение доставлено до сбоя последующей полезной нагрузки или побочного эффекта
failed Квитанция платформы не сформирована

Режим надёжности — один из required, best_effort или disabled (MessageDurabilityPolicy в src/channels/message/types.ts). required безопасно завершает работу с ошибкой, если сохраняемое намерение невозможно записать; best_effort переходит к непосредственной отправке, если сохранение недоступно; disabled сохраняет поведение непосредственной отправки до рефакторинга. Вспомогательные средства обратной совместимости по умолчанию используют disabled и не выводят required только из наличия у канала универсального адаптера исходящих сообщений.

Опасная граница сохраняется после успешного вызова платформы и до фиксации квитанции. Если процесс завершится в этот момент, ядро не сможет определить, существует ли сообщение платформы, если адаптер не объявляет reconcileUnknownSend. Этот хук классифицирует прерванную отправку как sent, not_sent или unresolved; только not_sent разрешает повторное воспроизведение. Каналы без механизма сопоставления переходят к состоянию unknown_after_send (src/channels/message/state.ts, src/infra/outbound/delivery-queue-recovery.ts) и могут выбрать повторное воспроизведение с семантикой «не менее одного раза», только если дублирование видимых сообщений является приемлемым и документированным компромиссом для этого канала.

Контекст получения

createMessageReceiveContext отслеживает состояние подтверждения/отклонения для каждого входящего события с идемпотентным ack() и явным nack(error). Политика подтверждения (ChannelMessageReceiveAckPolicy) принимает одно из следующих значений:

Политика Когда выполняется подтверждение
after_receive_record Ядро сохранило достаточно метаданных входящего сообщения для дедупликации/маршрутизации повторной доставки
after_agent_dispatch Запуск агента был диспетчеризирован
after_durable_send Сохраняемая исходящая отправка для этого хода была зафиксирована
manual Вызывающий код явно управляет моментом подтверждения (значение по умолчанию для адаптеров, которые не объявляют политику)

Опрос Telegram использует это для сохранения безопасной отметки завершённых обновлений (safeCompletedUpdateId в extensions/telegram/src/bot-update-tracker.ts): grammY по-прежнему наблюдает каждое обновление при его поступлении в цепочку промежуточного ПО, но OpenClaw продвигает сохраняемую отметку перезапуска только за обновления, которые завершили диспетчеризацию, поэтому неудачные или всё ещё ожидающие обновления воспроизводятся после перезапуска. Смещение вышестоящего getUpdates Telegram по-прежнему контролирует grammY; полностью надёжный источник опроса, управляющий повторной доставкой на уровне платформы за пределами этой отметки, не реализован (см. «Открытые вопросы»).

Предварительный просмотр в реальном времени

src/channels/message/live.ts моделирует предварительный просмотр, редактирование и завершение как единый жизненный цикл: createLiveMessageState, markLiveMessagePreviewUpdated, markLiveMessageFinalized, markLiveMessageCancelled и deliverFinalizableLivePreviewAdapter (сформировать окончательную правку из черновика, применить её и перейти к обычной отправке, если редактирование невозможно или завершается с ошибкой). LiveMessageState.phase — это idle | previewing | finalizing | finalized | cancelled; canFinalizeInPlace определяет, может ли предварительный просмотр стать окончательным сообщением посредством редактирования вместо новой отправки.

Сохраняемые квитанции

MessageReceipt (src/channels/message/types.ts) нормализует один или несколько идентификаторов сообщений платформы из одной логической отправки в platformMessageIds, а также parts для каждой части (тип, индекс, идентификатор ветки, идентификатор сообщения, на которое дан ответ). Основной идентификатор сохраняется для ветвления и последующих изменений. Благодаря этому составные доставки (текст с медиафайлами, текст, разбитый на части, резервное отображение карточки) можно повторно воспроизводить и дедуплицировать после перезапуска.

Сокращение общедоступного SDK

Рефакторинг поглотил или объявил устаревшими: reply-runtime, reply-dispatch-runtime, reply-reference, reply-chunking, вспомогательные средства reply-payload, представленные как общедоступный API, inbound-reply-dispatch, channel-reply-pipeline и большинство общедоступных применений outbound-runtime. Теперь src/plugin-sdk/channel-message.ts — это модуль повторного экспорта @deprecated, указывающий на channel-outbound / channel-inbound; псевдонимы среды выполнения channel.turn были удалены, а старая страница документации /plugins/sdk-channel-turn перенаправляет на API входящих сообщений канала. Код новых плагинов должен напрямую использовать channel-outbound и channel-inbound.

Где реализация разошлась с первоначальным проектом

Приведённый ниже проектный эскиз никогда не был реализован буквально в описанном виде. Он сохранён для исторической точности; не считайте эти имена типов текущим API.

  • Нет MessageOrigin / shouldDropOpenClawEcho. Первоначальный план предусматривал тег происхождения source: "openclaw" для сообщений об ошибках Gateway, а также общий предикат, удаляющий помеченные повторные сообщения от бота в общих комнатах до авторизации allowBots. Этот тип и предикат отсутствуют в кодовой базе. Сам allowBots является реальным ключом конфигурации для каждого канала (Slack, Discord, Google Chat и других), но механизм тегов происхождения, который должен был его защищать, так и не был реализован. Подавление повторных сообщений об ошибках Gateway в комнатах с включёнными ботами остаётся открытым пробелом, а не реализованной гарантией.
  • Нет единого пространства имён core.messages.receive/send/live/state. Реализованные функции находятся непосредственно в src/channels/message/* (withDurableMessageSendContext, createMessageReceiveContext, createLiveMessageState, classifyDurableSendRecoveryState), а не за фасадом core.messages.*.
  • Нет универсального нормализованного типа сообщений ChannelMessage / MessageTarget / MessageRelation. Ядро по-прежнему передаёт конкретные полезные нагрузки ответов (ReplyPayload) и контексты отдельных каналов через адаптеры отправки, а не единую платформонезависимую структуру сообщения с отношением kind: "reply" | "followup" | "broadcast" | "system".
  • Имена политик подтверждения отличаются от эскиза. Реализовано: after_receive_record | after_agent_dispatch | after_durable_send | manual. В первоначальном эскизе использовался immediate | after-record | after-durable-send | manual с полем причины тайм-аута Webhook; такая структура не была реализована.
  • Ключи возможностей DurableFinalDeliveryRequirementMap заменили предложенный в эскизе объект MessageCapabilities. Возможности представлены плоскими логическими флагами (text, media, poll, payload, silent, replyTo, thread, nativeQuote, messageSendingHooks, batch, reconcileUnknownSend, afterSendSuccess, afterCommit), проверяемыми через verifyDurableFinalCapabilityProofs, а не вложенной структурой в стиле text.chunking / attachments.voice.

Конкретные риски миграции (по-прежнему актуальны)

Эти побочные эффекты, относящиеся к отдельным каналам, появились до рефакторинга и должны продолжать работать через новые пути отправки. Они не гипотетические: каждый из них реализован и критически важен сегодня.

  • iMessage (extensions/imessage/src/monitor/echo-cache.ts, persisted-echo-cache.ts): после успешной отправки средство мониторинга записывает отправленные сообщения в кеш эха. При завершающих отправках с гарантированной доставкой этот кеш по-прежнему должен заполняться, иначе OpenClaw может повторно принять собственные ответы как входящие сообщения пользователя.
  • Tlon (extensions/tlon/src/monitor/index.ts): добавляет необязательную подпись модели и после ответов в группах регистрирует ветки, в которых было участие. Доставка с гарантированной сохранностью не должна обходить эти действия.
  • Discord и другие подготовленные диспетчеры уже самостоятельно обеспечивают прямую доставку и предварительный просмотр. Канал не обеспечивает сквозную гарантированную доставку, пока его подготовленный диспетчер явно не направляет завершающие сообщения через контекст отправки; не следует считать, что одного универсального адаптера для этого достаточно.
  • Резервная доставка без уведомления в Telegram после разбиения на части или резервного проецирования должна доставлять весь массив спроецированной полезной нагрузки, а не только первый элемент.
  • LINE, Zalo, Nostr и аналогичные вспомогательные пути могут поддерживать обработку токенов ответа, проксирование медиафайлов, кеши отправленных сообщений или цели, доступные только через обратные вызовы. Доставка для них остаётся в ведении канала, пока эта семантика не будет представлена адаптером отправки и покрыта тестами.
  • Вспомогательные средства для прямых личных сообщений могут иметь обратный вызов ответа, который является единственной корректной транспортной целью. Универсальный механизм исходящей отправки не должен определять цель по необработанным полям платформы и пропускать этот обратный вызов.

Классификация сбоев

Адаптеры классифицируют транспортные сбои по закрытым категориям в стиле DeliveryFailureKind (временный сбой, ограничение частоты запросов, аутентификация, разрешение, объект не найден, недопустимая полезная нагрузка, конфликт, отмена, неизвестная ошибка). Политика ядра:

  • Повторять попытки при временных сбоях и ограничении частоты запросов.
  • Не повторять попытки при недопустимой полезной нагрузке, если не предусмотрен резервный способ её формирования.
  • Не повторять попытки при сбоях аутентификации или разрешений до изменения конфигурации.
  • Если объект не найден, разрешить механизму завершения активной отправки перейти от редактирования к новой отправке, когда канал объявляет такой переход безопасным.
  • При конфликте использовать состояние квитанции и идемпотентности, чтобы определить, существует ли сообщение.
  • Любая ошибка после вызова платформы, который мог завершиться успешно, но до фиксации квитанции становится unknown_after_send, если адаптер не докажет, что операция на платформе не была выполнена.

Открытые вопросы

  • Следует ли Telegram со временем заменить средство запуска опроса grammY (1.43.0) на полностью устойчивый источник опроса, который управляет повторной доставкой на уровне платформы, а не только сохраняемой OpenClaw отметкой перезапуска (safeCompletedUpdateId).
  • Должно ли состояние предварительного просмотра в реальном времени храниться в одной записи с намерением завершающей отправки или в отдельном связанном хранилище текущего состояния.
  • Требуется ли для подавления эха при сбое Gateway в общих комнатах с ботами изначально запланированный механизм тегирования источника, более простой контракт для каждого канала или эта задача выходит за рамки текущей области.
  • Какие каналы изначально поддерживают источник и метаданные для подавления межботового эха, а каким требуется сохраняемый реестр исходящих сообщений.

Связанные материалы

Was this useful?
On this page

On this page