Messages and delivery
Рефакторинг жизненного цикла сообщений
Почему был выполнен этот рефакторинг
Стек каналов вырос из нескольких локальных исправлений: отдельных вспомогательных средств для входящих сообщений каждого
уровня зрелости (runtime.channel.inbound.run для простых адаптеров,
runtime.channel.inbound.runPreparedReply для расширенных), устаревших вспомогательных средств диспетчеризации ответов
(dispatchInboundReplyWithBase, recordInboundSessionAndDispatchReply),
потоковой передачи предварительного просмотра для отдельных каналов и обеспечения надёжности окончательной доставки, добавленного
поверх существующих путей полезной нагрузки ответов. Такая структура создала слишком много общедоступных концепций и
слишком много мест, где семантика доставки могла расходиться.
Пробел в надёжности, из-за которого потребовалось перепроектирование:
Обновление, полученное опросом 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 в общих комнатах с ботами изначально запланированный механизм тегирования источника, более простой контракт для каждого канала или эта задача выходит за рамки текущей области.
- Какие каналы изначально поддерживают источник и метаданные для подавления межботового эха, а каким требуется сохраняемый реестр исходящих сообщений.