diff --git a/CHANGELOG.md b/CHANGELOG.md index e50ce56dce7..8da443ef7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,12 @@ Docs: https://docs.openclaw.ai - Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant. - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun. +- Plugin SDK: add `openclaw/plugin-sdk/channel-message` lifecycle helpers for `defineChannelMessageAdapter`, `deliverInboundReplyWithMessageSendContext`, send/receive/live/state contracts, durable final-delivery capability derivation, capability proof helpers, and normalized message receipts. +- Plugin SDK: add `createChannelMessageAdapterFromOutbound` so channel plugins can derive durable message adapters from proven outbound adapters without duplicating send/receipt bridge code. +- Plugin SDK: add `actions.prepareSendPayload(...)` so channel plugins can shape message-tool sends into durable payloads while core owns queueing, hooks, retry, recovery, and acknowledgements. +- Plugin SDK: make the legacy `channel-reply-pipeline` subpath a compatibility wrapper over the shared reply core while steering root compat deprecations toward `plugin-sdk/channel-message`. +- Plugin SDK: move Discord, Slack, Mattermost, and Matrix live-preview finalization onto `plugin-sdk/channel-message` and attach message receipts to Telegram finalized previews plus Teams native stream finals, so preview edits and stream finals are represented in the message lifecycle instead of draft-only helpers. +- Telegram: persist the polling restart watermark after successful update dispatch instead of at handler entry, leaving failed updates retryable while still coalescing completed offsets safely. - Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc. - Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output. - Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure. @@ -118,6 +124,9 @@ Docs: https://docs.openclaw.ai - Doctor/Codex: repair legacy `openai-codex/*` routes in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel overrides, and stale session pins to canonical `openai/*`, selecting `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise select `agentRuntime.id: "pi"`. Thanks @vincentkoc. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc. +- Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations. +- Channels/message lifecycle: build legacy channel delivery results from message receipts and add receipts to BlueBubbles, Feishu, Google Chat, iMessage, IRC, LINE, Nextcloud Talk, QQ Bot, Signal, Synology Chat, Tlon, Twitch, WhatsApp, Zalo, and Zalo Personal send results and owner-path reply delivery plus Discord, Matrix, Mattermost, Slack, and Teams send results while preserving existing message id compatibility. +- iMessage: run durable final replies through the iMessage outbound sanitizer before sending, matching direct auto-reply delivery and preventing assistant-internal scaffolding from leaking through queued delivery. - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. - Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc. - CLI/plugins: handle closed stdin during `plugins uninstall` confirmation prompt and exit 1 with actionable `--force` guidance instead of crashing with Node exit 13 unsettled top-level await. Fixes #73562. (#73566) Thanks @ai-hpc. @@ -339,6 +348,7 @@ Docs: https://docs.openclaw.ai - Plugin tools: honor explicit tool denylists while selecting plugin tool runtimes, so denied plugin tools are not materialized for direct command or gateway surfaces before later policy filtering. Thanks @vincentkoc. - Plugin tools: filter factory-returned tools by manifest per-tool optional policy, so optional sibling tools from a shared runtime factory stay hidden unless explicitly allowed. Thanks @vincentkoc. - Agents/transcripts: retry context-overflow compaction from the current transcript only after the inbound user turn was actually persisted, and keep WebChat agent-run live delivery from writing duplicate Pi-managed assistant turns. Fixes #76424. (#77033) +- Messaging: queue assembled channel-turn final replies before sending to reduce response loss when the gateway restarts between assistant completion and channel delivery. Refs #77000. - Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946. - Channels/CLI: keep `openclaw channels list --json` usable when provider usage fetching fails, and report per-provider usage errors without aborting the channel list. Refs #67595. - Agents/messaging: deliver distinct final commentary after same-target `message` tool sends while still deduping text/media already sent by the tool, so short closing remarks are no longer silently dropped. Fixes #76915. Thanks @hclsys. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0e0b7e6ea89..32846382977 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -2164ea491c61e643f0a9c68f7b9bd2e41ab338eb93bbdf301da2fae548722581 plugin-sdk-api-baseline.json -c07c3719218a12482e2a76e6b9654da2ddddf75d8d70145cdaef3da2b2eaccef plugin-sdk-api-baseline.jsonl +fe061b6f35adb2b152d8f48244a94d4934b335143cc5f5aebb8cc96e5ba8b287 plugin-sdk-api-baseline.json +495248d5981456192aaf7da2ed23d5951eaa6d9e59d70c716ab91c3da3620e73 plugin-sdk-api-baseline.jsonl diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index faa84025a02..bdd856eab0b 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -27,6 +27,14 @@ "source": "OpenClaw App SDK API design", "target": "OpenClaw 应用 SDK API 设计" }, + { + "source": "Message lifecycle refactor", + "target": "消息生命周期重构" + }, + { + "source": "Channel message API", + "target": "频道消息 API" + }, { "source": "Azure Speech", "target": "Azure Speech" @@ -727,6 +735,18 @@ "source": "Codex Harness Context Engine Port", "target": "Codex Harness Context Engine Port" }, + { + "source": "Plugin refactor plan", + "target": "插件重构计划" + }, + { + "source": "Retry policy", + "target": "重试策略" + }, + { + "source": "Channel turn kernel", + "target": "频道轮次内核" + }, { "source": "/gateway/configuration#strict-validation", "target": "/gateway/configuration#strict-validation" diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 5a332d9dc83..78ecf33f608 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -756,6 +756,8 @@ curl "https://api.telegram.org/bot/getUpdates" Default is long polling. For webhook mode set `channels.telegram.webhookUrl` and `channels.telegram.webhookSecret`; optional `webhookPath`, `webhookHost`, `webhookPort` (defaults `/telegram-webhook`, `127.0.0.1`, `8787`). + In long-polling mode OpenClaw persists its restart watermark only after an update dispatches successfully. If a handler fails, that update remains retryable in the same process and is not written as completed for restart dedupe. + The local listener binds to `127.0.0.1:8787`. For public ingress, either put a reverse proxy in front of the local port or set `webhookHost: "0.0.0.0"` intentionally. Webhook mode validates request guards, the Telegram secret token, and the JSON body before returning `200` to Telegram. diff --git a/docs/concepts/message-lifecycle-refactor.md b/docs/concepts/message-lifecycle-refactor.md new file mode 100644 index 00000000000..ce55c30425e --- /dev/null +++ b/docs/concepts/message-lifecycle-refactor.md @@ -0,0 +1,1128 @@ +--- +summary: "Design plan for the unified durable message receive, send, preview, edit, and streaming lifecycle" +read_when: + - Refactoring channel send or receive behavior + - Changing channel turn, reply dispatch, outbound queue, preview streaming, or plugin SDK message APIs + - Designing a new channel plugin that needs durable sends, receipts, previews, edits, or retries +title: "Message lifecycle refactor" +--- + +This page is the target design for replacing scattered channel turn, reply +dispatch, preview streaming, and outbound delivery helpers with one durable +message lifecycle. + +The short version: + +- The core primitives should be **receive** and **send**, not **reply**. +- A reply is only a relation on an outbound message. +- A turn is an inbound-processing convenience, not the owner of delivery. +- Sending must be context based: `begin`, render, preview or stream, final send, + commit, fail. +- Receiving must be context based too: normalize, dedupe, route, record, + dispatch, platform ack, fail. +- The public plugin SDK should collapse to one small channel-message surface. + +## Problems + +The current channel stack grew from several valid local needs: + +- Simple inbound adapters use `runtime.channel.turn.run`. +- Rich adapters use `runtime.channel.turn.runPrepared`. +- Legacy helpers use `dispatchInboundReplyWithBase`, + `recordInboundSessionAndDispatchReply`, reply payload helpers, reply chunking, + reply references, and outbound runtime helpers. +- Preview streaming lives in channel-specific dispatchers. +- Final delivery durability is being added around existing reply payload paths. + +That shape fixes local bugs, but it leaves OpenClaw with too many public +concepts and too many places where delivery semantics can drift. + +The reliability issue that exposed this is: + +```text +Telegram polling update acked + -> assistant final text exists + -> process restarts before sendMessage succeeds + -> final response is lost +``` + +The target invariant is broader than Telegram: once core decides a visible +outbound message should exist, the intent must be durable before the platform +send is attempted, and the platform receipt must be committed after success. +That gives OpenClaw at-least-once recovery. Exactly-once behavior exists only +for adapters that can prove native idempotency or reconcile an +unknown-after-send attempt against platform state before replay. + +That is the end state for this refactor, not a description of every current +path. During migration, existing outbound helpers can still fall through to a +direct send when best-effort queue writes fail. The refactor is complete only +when durable final sends fail closed or explicitly opt out with a documented +non-durable policy. + +## Goals + +- One core lifecycle for all channel message receive and send paths. +- Durable final sends by default in the new message lifecycle after an adapter + declares replay-safe behavior. +- Shared preview, edit, stream, finalization, retry, recovery, and receipt + semantics. +- A small plugin SDK surface that third-party plugins can learn and maintain. +- Compatibility for existing `channel.turn` callers during migration. +- Clear extension points for new channel capabilities. +- No platform-specific branches in core. +- No token-delta channel messages. Channel streaming remains message preview, + edit, append, or completed block delivery. +- Structured OpenClaw-origin metadata for operational/system output so visible + gateway failures do not re-enter shared bot-enabled rooms as fresh prompts. + +## Non Goals + +- Do not remove `runtime.channel.turn.*` in the first phase. +- Do not force every channel into the same native transport behavior. +- Do not teach core Telegram topics, Slack native streams, Matrix redactions, + Feishu cards, QQ voice, or Teams activities. +- Do not publish all internal migration helpers as stable SDK API. +- Do not make retries replay completed non-idempotent platform operations. + +## Reference Model + +Vercel Chat has a good public mental model: + +- `Chat` +- `Thread` +- `Channel` +- `Message` +- adapter methods such as `postMessage`, `editMessage`, `deleteMessage`, + `stream`, `startTyping`, and history fetches +- a state adapter for dedupe, locks, queues, and persistence + +OpenClaw should borrow the vocabulary, not copy the surface. + +What OpenClaw needs beyond that model: + +- Durable outbound send intents before direct transport calls. +- Explicit send contexts with begin, commit, and fail. +- Receive contexts that know platform ack policy. +- Receipts that survive restart and can drive edits, deletes, recovery, and + duplicate suppression. +- A smaller public SDK. Bundled plugins can use internal runtime helpers, but + third-party plugins should see one coherent message API. +- Agent-specific behavior: sessions, transcripts, block streaming, tool + progress, approvals, media directives, silent replies, and group mention + history. + +`thread.post()` style promises are not enough for OpenClaw. They hide the +transaction boundary that decides whether a send is recoverable. + +## Core Model + +The new domain should live under an internal core namespace such as +`src/channels/message/*`. + +It has four concepts: + +```typescript +core.messages.receive(...) +core.messages.send(...) +core.messages.live(...) +core.messages.state(...) +``` + +`receive` owns inbound lifecycle. + +`send` owns outbound lifecycle. + +`live` owns preview, edit, progress, and stream state. + +`state` owns durable intent storage, receipts, idempotency, recovery, locks, and +dedupe. + +## Message Terms + +### Message + +A normalized message is platform-neutral: + +```typescript +type ChannelMessage = { + id: string; + channel: string; + accountId?: string; + direction: "inbound" | "outbound"; + target: MessageTarget; + sender?: MessageActor; + body?: MessageBody; + attachments?: MessageAttachment[]; + relation?: MessageRelation; + origin?: MessageOrigin; + timestamp?: number; + raw?: unknown; +}; +``` + +### Target + +The target describes where the message lives: + +```typescript +type MessageTarget = { + kind: "direct" | "group" | "channel" | "thread"; + id: string; + label?: string; + spaceId?: string; + parentId?: string; + threadId?: string; + nativeChannelId?: string; +}; +``` + +### Relation + +Reply is a relation, not an API root: + +```typescript +type MessageRelation = + | { + kind: "reply"; + inboundMessageId?: string; + replyToId?: string; + threadId?: string; + quote?: MessageQuote; + } + | { + kind: "followup"; + sessionKey?: string; + previousMessageId?: string; + } + | { + kind: "broadcast"; + reason?: string; + } + | { + kind: "system"; + reason: + | "approval" + | "task" + | "hook" + | "cron" + | "subagent" + | "message_tool" + | "cli" + | "control_ui" + | "automation" + | "error"; + }; +``` + +This lets the same send path handle normal replies, cron notifications, approval +prompts, task completions, message-tool sends, CLI or Control UI sends, subagent +results, and automation sends. + +### Origin + +Origin describes who produced a message and how OpenClaw should treat echoes of +that message. It is separate from relation: a message can be a reply to a user +and still be OpenClaw-originated operational output. + +```typescript +type MessageOrigin = + | { + source: "openclaw"; + schemaVersion: 1; + kind: "gateway_failure"; + code: "agent_failed_before_reply" | "missing_api_key" | "model_login_expired"; + echoPolicy: "drop_bot_room_echo"; + } + | { + source: "user" | "external_bot" | "platform" | "unknown"; + }; +``` + +Core owns the meaning of OpenClaw-originated output. Channels own how that +origin is encoded into their transport. + +The first required use is gateway failure output. Humans should still see +messages such as "Agent failed before reply" or "Missing API key", but tagged +OpenClaw operational output must not be accepted as bot-authored input in shared +rooms when `allowBots` is enabled. + +### Receipt + +Receipts are first-class: + +```typescript +type MessageReceipt = { + primaryPlatformMessageId?: string; + platformMessageIds: string[]; + parts: MessageReceiptPart[]; + threadId?: string; + replyToId?: string; + editToken?: string; + deleteToken?: string; + url?: string; + sentAt: number; + raw?: unknown; +}; + +type MessageReceiptPart = { + platformMessageId: string; + kind: "text" | "media" | "voice" | "card" | "preview" | "unknown"; + index: number; + threadId?: string; + replyToId?: string; + editToken?: string; + deleteToken?: string; + url?: string; + raw?: unknown; +}; +``` + +Receipts are the bridge from durable intent to future edit, delete, preview +finalization, duplicate suppression, and recovery. + +A receipt can describe one platform message or a multi-part delivery. Chunked +text, media plus text, voice plus text, and card fallbacks must preserve all +platform ids while still exposing a primary id for threading and later edits. + +## Receive Context + +Receiving should not be a bare helper call. The core needs a context that knows +dedupe, routing, session recording, and platform ack policy. + +```typescript +type MessageReceiveContext = { + id: string; + channel: string; + accountId?: string; + input: ChannelMessage; + ack: ReceiveAckController; + route: MessageRouteController; + session: MessageSessionController; + log: MessageLifecycleLogger; + + dedupe(): Promise; + resolve(): Promise; + record(resolved: ResolvedInboundMessage): Promise; + dispatch(recorded: RecordResult): Promise; + commit(result: DispatchResult): Promise; + fail(error: unknown): Promise; +}; +``` + +Receive flow: + +```text +platform event + -> begin receive context + -> normalize + -> classify + -> dedupe and self-echo gate + -> route and authorize + -> record inbound session metadata + -> dispatch agent run + -> durable outbound sends happen through send context + -> commit receive + -> ack platform when policy allows +``` + +Ack is not one thing. The receive contract must keep these signals separate: + +- **Transport ack:** tells the platform webhook or socket that OpenClaw accepted + the event envelope. Some platforms require this before dispatch. +- **Polling offset ack:** advances a cursor so the same event is not fetched + again. This must not advance past work that cannot be recovered. +- **Inbound record ack:** confirms OpenClaw persisted enough inbound metadata to + dedupe and route a redelivery. +- **User-visible receipt:** optional read/status/typing behavior; never a + durability boundary. + +`ReceiveAckPolicy` controls transport or polling acknowledgement only. It must +not be reused for read receipts or status reactions. + +Before bot authorization, receive must apply the shared OpenClaw echo policy +when the channel can decode message origin metadata: + +```typescript +function shouldDropOpenClawEcho(params: { + origin?: MessageOrigin; + isBotAuthor: boolean; + isRoomish: boolean; +}): boolean { + return ( + params.isBotAuthor && + params.isRoomish && + params.origin?.source === "openclaw" && + params.origin.kind === "gateway_failure" && + params.origin.echoPolicy === "drop_bot_room_echo" + ); +} +``` + +This drop is tag-based, not text-based. A bot-authored room message with the +same visible gateway-failure text but without OpenClaw origin metadata still +goes through normal `allowBots` authorization. + +Ack policy is explicit: + +```typescript +type ReceiveAckPolicy = + | { kind: "immediate"; reason: "webhook-timeout" | "platform-contract" } + | { kind: "after-record" } + | { kind: "after-durable-send" } + | { kind: "manual" }; +``` + +Telegram polling now uses the receive-context ack policy for its persisted +restart watermark. The tracker still observes grammY updates as they enter the +middleware chain, but OpenClaw persists only the safe completed update id after +successful dispatch, leaving failed or lower pending updates replayable after a +restart. Telegram's upstream `getUpdates` fetch offset is still controlled by +the polling library, so the remaining deeper cut is a fully durable polling +source if we need platform-level redelivery beyond OpenClaw's restart +watermark. Webhook platforms may need immediate HTTP ack, but they still need +inbound dedupe and durable outbound send intents because webhooks can redeliver. + +## Send Context + +Sending is also context based: + +```typescript +type MessageSendContext = { + id: string; + channel: string; + accountId?: string; + message: ChannelMessage; + intent: DurableSendIntent; + attempt: number; + signal: AbortSignal; + previousReceipt?: MessageReceipt; + preview?: LiveMessageState; + log: MessageLifecycleLogger; + + render(): Promise; + previewUpdate(rendered: RenderedMessageBatch): Promise; + send(rendered: RenderedMessageBatch): Promise; + edit(receipt: MessageReceipt, rendered: RenderedMessageBatch): Promise; + delete(receipt: MessageReceipt): Promise; + commit(receipt: MessageReceipt): Promise; + fail(error: unknown): Promise; +}; +``` + +Preferred orchestration: + +```typescript +await core.messages.withSendContext(message, async (ctx) => { + const rendered = await ctx.render(); + + if (ctx.preview?.canFinalizeInPlace) { + return await ctx.edit(ctx.preview.receipt, rendered); + } + + return await ctx.send(rendered); +}); +``` + +The helper expands to: + +```text +begin durable intent + -> render + -> optional preview/edit/stream work + -> mark sending + -> final platform send or final edit + -> mark committing with raw receipt + -> commit receipt + -> ack durable intent + -> fail durable intent on classified failure +``` + +The intent must exist before transport I/O. A restart after begin but before +commit is recoverable. + +The dangerous boundary is after platform success and before receipt commit. If a +process dies there, OpenClaw cannot know whether the platform message exists +unless the adapter provides native idempotency or a receipt reconciliation path. +Those attempts must resume in `unknown_after_send`, not blindly replay. Channels +without reconciliation may choose at-least-once replay only if duplicate visible +messages are an acceptable, documented tradeoff for that channel and relation. +The current SDK reconciliation bridge requires the adapter to declare +`reconcileUnknownSend`, then asks `durableFinal.reconcileUnknownSend` to +classify an unknown entry as `sent`, `not_sent`, or `unresolved`; only `not_sent` +permits replay, and unresolved entries stay terminal or retry only the +reconciliation check. + +Durability policy must be explicit: + +```typescript +type MessageDurabilityPolicy = "required" | "best_effort" | "disabled"; +``` + +`required` means core must fail closed when it cannot write the durable intent. +`best_effort` can fall through when persistence is unavailable. `disabled` keeps +the old direct send behavior. During migration, legacy wrappers and public +compatibility helpers default to `disabled`; they must not infer `required` from +the fact that a channel has a generic outbound adapter. + +Send contexts also own channel-local post-send effects. A migration is not safe +if durable delivery bypasses local behavior that was previously attached to the +channel's direct send path. Examples include self-echo suppression caches, +thread participation markers, native edit anchors, model-signature rendering, +and platform-specific duplicate guards. Those effects must either move into the +send adapter, the render adapter, or a named send-context hook before that +channel can enable durable generic final delivery. + +Send helpers must return receipts all the way back to their caller. Durable +wrappers cannot swallow message ids or replace a channel delivery result with +`undefined`; buffered dispatchers use those ids for thread anchors, later edits, +preview finalization, and duplicate suppression. + +Fallback sends operate on batches, not single payloads. Silent-reply rewrites, +media fallback, card fallback, and chunk projection can all produce more than +one deliverable message, so a send context must either deliver the whole +projected batch or explicitly document why only one payload is valid. + +```typescript +type RenderedMessageBatch = { + units: RenderedMessageUnit[]; + atomicity: "all_or_retry_remaining" | "best_effort_parts"; + idempotencyKey: string; +}; + +type RenderedMessageUnit = { + index: number; + kind: "text" | "media" | "voice" | "card" | "preview" | "unknown"; + payload: unknown; + required: boolean; +}; +``` + +When such a fallback is durable, the whole projected batch must be represented by +one durable send intent or another atomic batch plan. Recording each payload +one-by-one is not enough: a crash between payloads can leave a partial visible +fallback with no durable record for the remaining payloads. Recovery must know +which units already have receipts and either replay only missing units or mark +the batch `unknown_after_send` until the adapter reconciles it. + +## Live Context + +Preview, edit, progress, and stream behavior should be one opt-in lifecycle. + +```typescript +type MessageLiveAdapter = { + begin?(ctx: MessageSendContext): Promise; + update?( + ctx: MessageSendContext, + state: LiveMessageState, + update: LiveMessageUpdate, + ): Promise; + finalize?( + ctx: MessageSendContext, + state: LiveMessageState, + final: RenderedMessageBatch, + ): Promise; + cancel?( + ctx: MessageSendContext, + state: LiveMessageState, + reason: LiveCancelReason, + ): Promise; +}; +``` + +Live state is durable enough to recover or suppress duplicates: + +```typescript +type LiveMessageState = { + mode: "partial" | "block" | "progress" | "native"; + receipt?: MessageReceipt; + visibleSince?: number; + canFinalizeInPlace: boolean; + lastRenderedHash?: string; + staleAfterMs?: number; +}; +``` + +This should cover current behavior: + +- Telegram send plus edit preview, with fresh final after stale preview age. +- Discord send plus edit preview, cancel on media/error/explicit reply. +- Slack native stream or draft preview depending on thread shape. +- Mattermost draft post finalization. +- Matrix draft event finalization or redaction on mismatch. +- Teams native progress stream. +- QQ Bot stream or accumulated fallback. + +## Adapter Surface + +The public SDK target should be one subpath: + +```typescript +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; +``` + +Target shape: + +```typescript +type ChannelMessageAdapter = { + receive?: MessageReceiveAdapter; + send: MessageSendAdapter; + live?: MessageLiveAdapter; + origin?: MessageOriginAdapter; + render?: MessageRenderAdapter; + capabilities: MessageCapabilities; +}; +``` + +Send adapter: + +```typescript +type MessageSendAdapter = { + send(ctx: MessageSendContext, rendered: RenderedMessageBatch): Promise; + edit?( + ctx: MessageSendContext, + receipt: MessageReceipt, + rendered: RenderedMessageBatch, + ): Promise; + delete?(ctx: MessageSendContext, receipt: MessageReceipt): Promise; + classifyError?(ctx: MessageSendContext, error: unknown): DeliveryFailureKind; + reconcileUnknownSend?(ctx: MessageSendContext): Promise; + afterSendSuccess?(ctx: MessageSendContext, receipt: MessageReceipt): Promise; + afterCommit?(ctx: MessageSendContext, receipt: MessageReceipt): Promise; +}; +``` + +Receive adapter: + +```typescript +type MessageReceiveAdapter = { + normalize(raw: TRaw, ctx: MessageNormalizeContext): Promise; + classify?(message: ChannelMessage): Promise; + preflight?(message: ChannelMessage, event: MessageEventClass): Promise; + ackPolicy?(message: ChannelMessage, event: MessageEventClass): ReceiveAckPolicy; +}; +``` + +Before preflight authorization, core must run the shared OpenClaw echo predicate +whenever `origin.decode` returns OpenClaw-origin metadata. The receive adapter +supplies platform facts such as bot author and room shape; core owns the drop +decision and ordering so channels do not reimplement text filters. + +Origin adapter: + +```typescript +type MessageOriginAdapter = { + encode?(origin: MessageOrigin): TNative | undefined; + decode?(raw: TRaw): MessageOrigin | undefined; +}; +``` + +Core sets `MessageOrigin`. Channels only translate it to and from native +transport metadata. Slack maps this to `chat.postMessage({ metadata })` and +inbound `message.metadata`; Matrix can map it to extra event content; channels +without native metadata can use a receipt/outbound registry when that is the +best available approximation. + +Capabilities: + +```typescript +type MessageCapabilities = { + text: { maxLength?: number; chunking?: boolean }; + attachments?: { + upload: boolean; + remoteUrl: boolean; + voice?: boolean; + }; + threads?: { + reply: boolean; + topic?: boolean; + nativeThread?: boolean; + }; + live?: { + edit: boolean; + delete: boolean; + nativeStream?: boolean; + progress?: boolean; + }; + delivery?: { + idempotencyKey?: boolean; + retryAfter?: boolean; + receiptRequired?: boolean; + }; +}; +``` + +## Public SDK Reduction + +The new public surface should absorb or deprecate these conceptual areas: + +- `reply-runtime` +- `reply-dispatch-runtime` +- `reply-reference` +- `reply-chunking` +- `reply-payload` +- `inbound-reply-dispatch` +- `channel-reply-pipeline` +- most public uses of `outbound-runtime` +- ad hoc draft stream lifecycle helpers + +Compatibility subpaths can remain as wrappers, but new third-party plugins +should not need them. + +Bundled plugins may keep internal helper imports through reserved runtime +subpaths while migrating. Public docs should steer plugin authors to +`plugin-sdk/channel-message` once it exists. + +## Relationship To Channel Turn + +`runtime.channel.turn.*` should stay during migration. + +It should become a compatibility adapter: + +```text +channel.turn.run + -> messages.receive context + -> session dispatch + -> messages.send context for visible output +``` + +`channel.turn.runPrepared` should also remain initially: + +```text +channel-owned dispatcher + -> messages.receive record/finalize bridge + -> messages.live for preview/progress + -> messages.send for final delivery +``` + +After all bundled plugins and known third-party compatibility paths are bridged, +`channel.turn` can be deprecated. It should not be removed until there is a +published SDK migration path and contract tests proving old plugins still work +or fail with a clear version error. + +## Compatibility Guardrails + +During migration, generic durable delivery is opt-in for any channel whose +existing delivery callback has side effects beyond "send this payload". + +Legacy entry points are non-durable by default: + +- `channel.turn.run` and `dispatchAssembledChannelTurn` use the channel's + delivery callback unless that channel explicitly supplies an audited durable + policy/options object. +- `channel.turn.runPrepared` stays channel-owned until the prepared dispatcher + explicitly calls the send context. +- Public compatibility helpers such as `recordInboundSessionAndDispatchReply`, + `dispatchInboundReplyWithBase`, and direct-DM helpers never inject generic + durable delivery before the caller-provided `deliver` or `reply` callback. + +For migration bridge types, `durable: undefined` means "not durable". The +durable path is enabled only by an explicit policy/options value. `durable: +false` can remain as a compatibility spelling, but implementation should not +require every unmigrated channel to add it. + +Current bridge code must keep the durability decision explicit: + +- Durable final delivery returns a discriminated status. `handled_visible` and + `handled_no_send` are terminal; `unsupported` and `not_applicable` may fall + back to channel-owned delivery; `failed` propagates the send failure. +- Generic durable final delivery is gated by adapter capabilities such as + silent delivery, reply target preservation, native quote preservation, and + message-sending hooks. Missing parity should choose channel-owned delivery, + not a generic send that changes user-visible behavior. +- Queue-backed durable sends expose a delivery intent reference. Existing + `pendingFinalDelivery*` session fields can carry the intent id during the + transition; the end state is a `MessageSendIntent` store instead of frozen + reply text plus ad hoc context fields. + +Do not enable the generic durable path for a channel until all of these are +true: + +- The generic send adapter executes the same rendering and transport behavior as + the old direct path. +- Local post-send side effects are preserved through the send context. +- The adapter returns receipts or delivery results with all platform message + ids. +- Prepared dispatcher paths either call the new send context or stay documented + as outside the durable guarantee. +- Fallback delivery handles every projected payload, not only the first one. +- Durable fallback delivery records the whole projected payload array as one + replayable intent or batch plan. + +Concrete migration hazards to preserve: + +- iMessage monitor delivery records sent messages in an echo cache after a + successful send. Durable final sends must still populate that cache, otherwise + OpenClaw can re-ingest its own final replies as inbound user messages. +- Tlon appends an optional model signature and records participated threads + after group replies. Generic durable delivery must not bypass those effects; + either move them into Tlon render/send/finalize adapters or keep Tlon on the + channel-owned path. +- Discord and other prepared dispatchers already own direct delivery and preview + behavior. They are not covered by an assembled-turn durable guarantee until + their prepared dispatchers explicitly route finals through the send context. +- Telegram silent fallback delivery must deliver the full projected payload + array. A single-payload shortcut can drop additional fallback payloads after + projection. +- LINE, BlueBubbles, Zalo, Nostr, and other existing assembled/helper paths may + have reply-token handling, media proxying, sent-message caches, loading/status + cleanup, or callback-only targets. They stay on channel-owned delivery until + those semantics are represented by the send adapter and verified by tests. +- Direct-DM helpers can have a reply callback that is the only correct transport + target. Generic outbound must not guess from `OriginatingTo` or `To` and skip + that callback. +- OpenClaw gateway failure output must stay visible to humans, but tagged + bot-authored room echoes must be dropped before `allowBots` authorization. + Channels must not implement this with visible-text prefix filters except as a + short emergency stopgap; the durable contract is structured origin metadata. + +## Internal Storage + +The durable queue should store message send intents, not reply payloads. + +```typescript +type DurableSendIntent = { + id: string; + idempotencyKey: string; + channel: string; + accountId?: string; + message: ChannelMessage; + batch?: RenderedMessageBatch; + liveState?: LiveMessageState; + status: + | "pending" + | "sending" + | "committing" + | "unknown_after_send" + | "sent" + | "failed" + | "cancelled"; + attempt: number; + nextAttemptAt?: number; + receipt?: MessageReceipt; + partialReceipt?: MessageReceipt; + failure?: DeliveryFailure; + createdAt: number; + updatedAt: number; +}; +``` + +Recovery loop: + +```text +load pending or sending intents + -> acquire idempotency lock + -> skip if receipt already committed + -> reconstruct send context + -> render if needed + -> reconcile unknown_after_send if needed + -> call adapter send/edit/finalize + -> commit receipt, mark unknown_after_send, or schedule retry +``` + +The queue should keep enough identity to replay through the same account, +thread, target, formatting policy, and media rules after restart. + +## Failure Classes + +Channel adapters classify transport failures into closed categories: + +```typescript +type DeliveryFailureKind = + | "transient" + | "rate_limit" + | "auth" + | "permission" + | "not_found" + | "invalid_payload" + | "conflict" + | "cancelled" + | "unknown"; +``` + +Core policy: + +- Retry `transient` and `rate_limit`. +- Do not retry `invalid_payload` unless a render fallback exists. +- Do not retry `auth` or `permission` until configuration changes. +- For `not_found`, let live finalization fall back from edit to fresh send when + the channel declares that safe. +- For `conflict`, use receipt/idempotency rules to decide whether the message + already exists. +- Any error after the adapter may have completed platform I/O but before receipt + commit becomes `unknown_after_send` unless the adapter can prove the platform + operation did not happen. + +## Channel Mapping + +| Channel | Target migration | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Telegram | Receive ack policy plus durable final sends. Live adapter owns send plus edit preview, stale preview final send, topics, quote-reply preview skip, media fallback, and retry-after handling. | +| Discord | Send adapter wraps existing durable payload delivery. Live adapter owns draft edit, progress draft, media/error preview cancel, reply target preservation, and message id receipts. Audit bot-authored gateway-failure echoes in shared rooms; use an outbound registry or other native equivalent if Discord cannot carry origin metadata on normal messages. | +| Slack | Send adapter handles normal chat posts. Live adapter chooses native stream when thread shape supports it, otherwise draft preview. Receipts preserve thread timestamps. Origin adapter maps OpenClaw gateway failures to Slack `chat.postMessage.metadata` and drops tagged bot-room echoes before `allowBots` authorization. | +| WhatsApp | Send adapter owns text/media send with durable final intents. Receive adapter handles group mention and sender identity. Live can stay absent until WhatsApp has an editable transport. | +| Matrix | Live adapter owns draft event edits, finalization, redaction, encrypted media constraints, and reply-target mismatch fallback. Receive adapter owns encrypted event hydration and dedupe. Origin adapter should encode OpenClaw gateway-failure origin into Matrix event content and drop configured-bot room echoes before `allowBots` handling. | +| Mattermost | Live adapter owns one draft post, progress/tool folding, finalization in place, and fresh-send fallback. | +| Microsoft Teams | Live adapter owns native progress and block stream behavior. Send adapter owns activities and attachment/card receipts. | +| Feishu | Render adapter owns text/card/raw rendering. Live adapter owns streaming cards and duplicate final suppression. Send adapter owns comments, topic sessions, media, and voice suppression. | +| QQ Bot | Live adapter owns C2C streaming, accumulator timeout, and fallback final send. Render adapter owns media tags and text-as-voice. | +| Signal | Simple receive plus send adapter. No live adapter unless signal-cli adds reliable edit support. | +| iMessage and BlueBubbles | Simple receive plus send adapter. iMessage send must preserve monitor echo-cache population before durable finals can bypass monitor delivery. BlueBubbles-specific typing, reactions, and attachments remain adapter capabilities. | +| Google Chat | Simple receive plus send adapter with thread relation mapped to spaces and thread ids. Audit `allowBots=true` room behavior for tagged OpenClaw gateway-failure echoes. | +| LINE | Simple receive plus send adapter with reply-token constraints modeled as target/relation capability. | +| Nextcloud Talk | SDK receive bridge plus send adapter. | +| IRC | Simple receive plus send adapter, no durable edit receipts. | +| Nostr | Receive plus send adapter for encrypted DMs; receipts are event ids. | +| QA Channel | Contract-test adapter for receive, send, live, retry, and recovery behavior. | +| Synology Chat | Simple receive plus send adapter. | +| Tlon | Send adapter must preserve model-signature rendering and participated-thread tracking before generic durable final delivery is enabled. | +| Twitch | Simple receive plus send adapter with rate-limit classification. | +| Zalo | Simple receive plus send adapter. | +| Zalo Personal | Simple receive plus send adapter. | + +## Migration Plan + +### Phase 1: Internal Message Domain + +- Add `src/channels/message/*` types for messages, targets, relations, + origins, receipts, capabilities, durable intents, receive context, send + context, live context, and failure classes. +- Add `origin?: MessageOrigin` to the migration bridge payload type used by + current reply delivery, then move that field to `ChannelMessage` and rendered + message types as the refactor replaces reply payloads. +- Keep this internal until adapters and tests prove the shape. +- Add pure unit tests for state transitions and serialization. + +### Phase 2: Durable Send Core + +- Move the existing outbound queue from reply-payload durability to durable + message send intents. +- Let a durable send intent carry a projected payload array or batch plan, not + only one reply payload. +- Preserve the current queue recovery behavior through compatibility conversion. +- Make `deliverOutboundPayloads` call `messages.send`. +- Make final-send durability the default and fail closed when the durable intent + cannot be written in the new message lifecycle, after the adapter declares + replay safety. Existing channel-turn and SDK compatibility paths remain + direct-send by default during this phase. +- Record receipts consistently. +- Return receipts and delivery results to the original dispatcher caller instead + of treating durable send as a terminal side effect. +- Persist message origin through durable send intents so recovery, replay, and + chunked sends preserve OpenClaw operational provenance. + +### Phase 3: Channel Turn Bridge + +- Reimplement `channel.turn.run` and `dispatchAssembledChannelTurn` on top of + `messages.receive` and `messages.send`. +- Keep current fact types stable. +- Keep legacy behavior by default. An assembled-turn channel becomes durable + only when its adapter explicitly opts in with a replay-safe durability policy. +- Keep `durable: false` as a compatibility escape hatch for paths that finalize + native edits and cannot replay safely yet, but do not rely on `false` markers + to protect unmigrated channels. +- Default assembled-turn durability only in the new message lifecycle, after + the channel mapping proves the generic send path preserves the old channel + delivery semantics. + +### Phase 4: Prepared Dispatcher Bridge + +- Replace `deliverDurableInboundReplyPayload` with a send-context bridge. +- Keep the old helper as a wrapper. +- Port Telegram, WhatsApp, Slack, Signal, iMessage, and Discord first because + they already have durable-final work or simpler send paths. +- Treat every prepared dispatcher as uncovered until it explicitly opts in to + the send context. Documentation and changelog entries must say "assembled + channel turns" or name the migrated channel paths rather than claiming all + automatic final replies. +- Keep `recordInboundSessionAndDispatchReply`, direct-DM helpers, and similar + public compatibility helpers behavior-preserving. They may expose an explicit + send-context opt-in later, but must not automatically attempt generic durable + delivery before the caller-owned delivery callback. + +### Phase 5: Unified Live Lifecycle + +- Build `messages.live` with two proof adapters: + - Telegram for send plus edit plus stale final send. + - Matrix for draft finalization plus redaction fallback. +- Then migrate Discord, Slack, Mattermost, Teams, QQ Bot, and Feishu. +- Delete duplicated preview finalization code only after each channel has + parity tests. + +### Phase 6: Public SDK + +- Add `openclaw/plugin-sdk/channel-message`. +- Document it as the preferred channel plugin API. +- Update package exports, entrypoint inventory, generated API baselines, and + plugin SDK docs. +- Include `MessageOrigin`, origin encode/decode hooks, and the shared + `shouldDropOpenClawEcho` predicate in the channel-message SDK surface. +- Keep compatibility wrappers for old subpaths. +- Mark reply-named SDK helpers as deprecated in docs after bundled plugins are + migrated. + +### Phase 7: All Senders + +Move all non-reply outbound producers onto `messages.send`: + +- cron and heartbeat notifications +- task completions +- hook results +- approval prompts and approval results +- message tool sends +- subagent completion announcements +- explicit CLI or Control UI sends +- automation/broadcast paths + +This is where the model stops being "agent replies" and becomes "OpenClaw sends +messages". + +### Phase 8: Deprecate Turn + +- Keep `channel.turn` as a wrapper for at least one compatibility window. +- Publish migration notes. +- Run plugin SDK compatibility tests against old imports. +- Remove or hide old internal helpers only after no bundled plugin needs them + and third-party contracts have a stable replacement. + +## Test Plan + +Unit tests: + +- Durable send intent serialization and recovery. +- Idempotency key reuse and duplicate suppression. +- Receipt commit and replay skip. +- `unknown_after_send` recovery that reconciles before replay when an adapter + supports reconciliation. +- Failure classification policy. +- Receive ack policy sequencing. +- Relation mapping for reply, followup, system, and broadcast sends. +- Gateway-failure origin factory and `shouldDropOpenClawEcho` predicate. +- Origin preservation through payload normalization, chunking, durable queue + serialization, and recovery. + +Integration tests: + +- `channel.turn.run` simple adapter still records and sends. +- Legacy assembled-turn delivery does not become durable unless the channel + explicitly opts in. +- `channel.turn.runPrepared` bridge still records and finalizes. +- Public compatibility helpers call caller-owned delivery callbacks by default + and do not generic-send before those callbacks. +- Durable fallback delivery replays the whole projected payload array after + restart and cannot leave the later payloads unrecorded after an early crash. +- Durable assembled-turn delivery returns platform message ids to the buffered + dispatcher. +- Custom delivery hooks still return platform message ids when durable delivery + is disabled or unavailable. +- Final reply survives restart between assistant completion and platform send. +- Preview draft finalizes in place when allowed. +- Preview draft is cancelled or redacted when media/error/reply-target mismatch + requires normal delivery. +- Block streaming and preview streaming do not both deliver the same text. +- Media streamed early is not duplicated in final delivery. + +Channel tests: + +- Telegram topic reply with polling ack delayed until the receive context's safe + completed watermark. +- Telegram polling recovery for accepted-but-not-delivered updates covered by + the persisted safe-completed offset model. +- Telegram stale preview sends fresh final and cleans up preview. +- Telegram silent fallback sends every projected fallback payload. +- Telegram silent fallback durability records the full projected fallback array + atomically, not one single-payload durable intent per loop iteration. +- Discord preview cancel on media/error/explicit reply. +- Discord prepared dispatcher finals route through the send context before docs + or changelog claim Discord final-reply durability. +- iMessage durable final sends populate the monitor sent-message echo cache. +- LINE, BlueBubbles, Zalo, and Nostr legacy delivery paths are not bypassed by + generic durable send until their adapter parity tests exist. +- Direct-DM/Nostr callback delivery remains authoritative unless explicitly + migrated to a complete message target and replay-safe send adapter. +- Slack tagged OpenClaw gateway failure messages stay visible outbound, tagged + bot-room echoes drop before `allowBots`, and untagged bot messages with the + same visible text still follow normal bot authorization. +- Slack native stream fallback to draft preview in top-level DMs. +- Matrix preview finalization and redaction fallback. +- Matrix tagged OpenClaw gateway-failure room echoes from configured bot + accounts drop before `allowBots` handling. +- Discord and Google Chat shared-room gateway-failure cascade audits cover + `allowBots` modes before claiming generic protection there. +- Mattermost draft finalization and fresh-send fallback. +- Teams native progress finalization. +- Feishu duplicate final suppression. +- QQ Bot accumulator timeout fallback. +- Tlon durable final sends preserve model-signature rendering and participated + thread tracking. +- WhatsApp, Signal, iMessage, Google Chat, LINE, IRC, Nostr, Nextcloud Talk, + Synology Chat, Tlon, Twitch, Zalo, and Zalo Personal simple durable final + sends. + +Validation: + +- Targeted Vitest files during development. +- `pnpm check:changed` in Testbox for the full changed surface. +- Broader `pnpm check` in Testbox before landing the complete refactor or after + public SDK/export changes. +- Live or qa-channel smoke for at least one edit-capable channel and one + simple send-only channel before removing compatibility wrappers. + +## Open Questions + +- Whether Telegram should eventually replace the grammY runner source with a + fully durable polling source that can control platform-level redelivery, not + only OpenClaw's persisted restart watermark. +- Whether durable live preview state should be stored in the same queue record + as the final send intent or in a sibling live-state store. +- How long compatibility wrappers stay documented after + `plugin-sdk/channel-message` ships. +- Whether third-party plugins should implement receive adapters directly or only + provide normalize/send/live hooks through `defineChannelMessageAdapter`. +- Which receipt fields are safe to expose in public SDK versus internal runtime + state. +- Whether side effects such as self-echo caches and participated-thread markers + should be modeled as send-context hooks, adapter-owned finalize steps, or + receipt subscribers. +- Which channels have native origin metadata, which need persisted outbound + registries, and which cannot offer reliable cross-bot echo suppression. + +## Acceptance Criteria + +- Every bundled message channel sends final visible output through + `messages.send`. +- Every inbound message channel enters through `messages.receive` or a + documented compatibility wrapper. +- Every preview/edit/stream channel uses `messages.live` for draft state and + finalization. +- `channel.turn` is only a wrapper. +- Reply-named SDK helpers are compatibility exports, not the recommended path. +- Durable recovery can replay pending final sends after restart without losing + the final response or duplicating already committed sends; sends whose + platform outcome is unknown are reconciled before replay or documented as + at-least-once for that adapter. +- Durable final sends fail closed when the durable intent cannot be written, + unless a caller explicitly selected a documented non-durable mode. +- Legacy channel-turn and SDK compatibility helpers default to direct + channel-owned delivery; generic durable send is explicit opt-in only. +- Receipts preserve all platform message ids for multi-part deliveries and a + primary id for threading/edit convenience. +- Durable wrappers preserve channel-local side effects before replacing direct + delivery callbacks. +- Prepared dispatchers are not counted as durable until their final delivery + path explicitly uses the send context. +- Fallback delivery handles every projected payload. +- Durable fallback delivery records every projected payload in one replayable + intent or batch plan. +- OpenClaw-originated gateway failure output is visible to humans but tagged + bot-authored room echoes are dropped before bot authorization on channels that + declare support for the origin contract. +- The docs explain send, receive, live, state, receipts, relations, failure + policy, migration, and test coverage. + +## Related + +- [Messages](/concepts/messages) +- [Streaming and chunking](/concepts/streaming) +- [Progress drafts](/concepts/progress-drafts) +- [Retry policy](/concepts/retry) +- [Channel turn kernel](/plugins/sdk-channel-turn) diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index e6a105892ae..8c18c756146 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -206,6 +206,7 @@ parent stays quiet until the child completion event delivers the real reply. ## Related +- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) - target durable send and receive design - [Streaming](/concepts/streaming) — real-time message delivery - [Retry](/concepts/retry) — message delivery retry behavior - [Queue](/concepts/queue) — message processing queue diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index ea03304e7f5..1461ab05fb1 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -242,6 +242,7 @@ Use the same shape under another compact progress channel key, for example `chan ## Related +- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) - target shared preview, edit, stream, and finalization design - [Progress drafts](/concepts/progress-drafts) — visible work-in-progress messages that update during long turns - [Messages](/concepts/messages) — message lifecycle and delivery - [Retry](/concepts/retry) — retry behavior on delivery failure diff --git a/docs/docs.json b/docs/docs.json index 473100fc5a4..e6c2c3743d1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1163,6 +1163,7 @@ "group": "Messages and delivery", "pages": [ "concepts/messages", + "concepts/message-lifecycle-refactor", "concepts/streaming", "concepts/progress-drafts", "concepts/retry", @@ -1205,6 +1206,7 @@ "plugins/building-plugins", "plugins/hooks", "plugins/sdk-channel-plugins", + "plugins/sdk-channel-message", "plugins/sdk-provider-plugins", "plugins/adding-capabilities", "plugins/compatibility", diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index 6e70d9ce76f..1cb31008742 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -466,7 +466,7 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a ~/.openclaw/agents//agent/auth-profiles.json ``` - To inspect saved profiles without dumping secrets, run `openclaw models auth list` (optionally `--provider ` or `--json`). See [Models CLI](/cli/models#openclaw-models-auth-list) for details. + To inspect saved profiles without dumping secrets, run `openclaw models auth list` (optionally `--provider ` or `--json`). See [Models CLI](/cli/models#auth-profiles) for details. diff --git a/docs/plugins/sdk-channel-message.md b/docs/plugins/sdk-channel-message.md new file mode 100644 index 00000000000..3186ba85a28 --- /dev/null +++ b/docs/plugins/sdk-channel-message.md @@ -0,0 +1,424 @@ +--- +summary: "Message lifecycle API for channel plugins, including durable sends, receipts, live preview, receive ack policy, and legacy migration" +title: "Channel message API" +read_when: + - You are building or refactoring a messaging channel plugin + - You need durable final reply delivery, receipts, live preview finalization, or receive acknowledgement policy + - You are migrating from legacy reply pipeline or inbound reply dispatch helpers +--- + +# Channel Message API + +Channel plugins should expose one `message` adapter from +`openclaw/plugin-sdk/channel-message`. The adapter describes the native message +lifecycle that the platform supports: + +```text +receive -> route and record -> agent turn -> durable final send +send -> render batch -> platform I/O -> receipt -> lifecycle side effects +live preview -> final edit or fallback -> receipt +``` + +Core owns queueing, durability, generic retry policy, hooks, receipts, and the +shared `message` tool. The plugin owns native send/edit/delete calls, target +normalization, platform threading, selected quotes, notification flags, account +state, and platform-specific side effects. + +Use this page together with [Building channel plugins](/plugins/sdk-channel-plugins). + +The `channel-message` subpath is intentionally cheap enough for hot plugin +bootstrap files such as `channel.ts`: it exposes adapter contracts, capability +proofs, receipts, and compatibility facades without loading outbound delivery. +Runtime delivery helpers are available from +`openclaw/plugin-sdk/channel-message-runtime` for monitor/send code paths that +are already doing asynchronous message I/O. + +## Minimal Adapter + +Most new channel plugins can start with a small adapter: + +```typescript +import { + defineChannelMessageAdapter, + createMessageReceiptFromOutboundResults, +} from "openclaw/plugin-sdk/channel-message"; + +export const demoMessageAdapter = defineChannelMessageAdapter({ + id: "demo", + durableFinal: { + capabilities: { + text: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + send: { + text: async ({ cfg, to, text, accountId, replyToId, threadId, signal }) => { + const sent = await sendDemoMessage({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + threadId: threadId == null ? undefined : String(threadId), + signal, + }); + + return { + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "demo", messageId: sent.id, conversationId: to }], + kind: "text", + threadId: threadId == null ? undefined : String(threadId), + replyToId: replyToId ?? undefined, + }), + }; + }, + }, +}); +``` + +Then attach it to the channel plugin: + +```typescript +export const demoPlugin = createChatChannelPlugin({ + base: { + id: "demo", + message: demoMessageAdapter, + // other channel plugin fields + }, +}); +``` + +Only declare capabilities that the adapter really preserves. Every declared +capability should have a contract test. + +## Outbound Bridge + +If the channel already has a compatible `outbound` adapter, prefer deriving the +message adapter instead of duplicating send code: + +```typescript +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; + +const demoMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "demo", + outbound: demoOutboundAdapter, +}); +``` + +The bridge converts old outbound send results into `MessageReceipt` values. New +code should pass receipts end to end and only derive legacy ids at compatibility +edges with `listMessageReceiptPlatformIds(...)` or +`resolveMessageReceiptPrimaryId(...)`. +If no receive policy is supplied, `createChannelMessageAdapterFromOutbound(...)` +uses `manual` receive acknowledgement policy. That makes plugin-owned platform +acknowledgement explicit without changing channels that acknowledge webhooks, +sockets, or polling offsets outside generic receive context. + +## Message Tool Sends + +The shared `message(action="send")` path should use the same core delivery +lifecycle as final replies. If a channel needs provider-specific shaping for the +tool send, implement `actions.prepareSendPayload(...)` instead of sending from +`actions.handleAction(...)`. + +`prepareSendPayload(...)` receives the normalized core `ReplyPayload` plus the +full action context. Return a payload with channel-specific data in +`payload.channelData.` and let core call `sendMessage(...)`, +`deliverOutboundPayloads(...)`, the write-ahead queue, message-sending hooks, +retry, recovery, and ack cleanup. + +Return `null` only when the send cannot be represented as a durable payload, for +example because it contains a non-serializable component factory. Core will keep +the legacy plugin action fallback for compatibility, but new channel send +features should be expressible as durable payload data. + +```typescript +export const demoActions: ChannelMessageActionAdapter = { + describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }), + prepareSendPayload: ({ ctx, payload }) => { + if (ctx.action !== "send") { + return null; + } + return { + ...payload, + channelData: { + ...payload.channelData, + demo: { + ...(payload.channelData?.demo as object | undefined), + nativeCard: ctx.params.card, + }, + }, + }; + }, +}; +``` + +The outbound adapter then reads `payload.channelData.demo` inside `sendPayload`. +This keeps platform-specific rendering in the plugin while core still owns +persist, retry, recover, hooks, and ack. + +Prepared `message(action="send")` payloads and generic final-reply delivery use +core delivery with best-effort queueing by default. Required durable queueing is +only valid after core verifies the channel can reconcile a send whose outcome is +unknown after a crash. If the adapter cannot implement `reconcileUnknownSend`, +keep the prepared send path best-effort; core will still try the write-ahead +queue, but queue persistence or uncertain crash recovery is not part of the +required delivery contract. + +## Durable Final Capabilities + +Durable final delivery is opt in per side effect. Core will only use generic +durable delivery when the adapter declares every capability needed by the +payload and delivery options. + +| Capability | Declare when | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `text` | The adapter can send text and return a receipt. | +| `media` | Media sends return receipts for every visible platform message. | +| `payload` | The adapter preserves rich reply payload semantics, not only text and one media URL. | +| `replyTo` | Native reply targets reach the platform. | +| `thread` | Native thread, topic, or channel thread targets reach the platform. | +| `silent` | Notification suppression reaches the platform. | +| `nativeQuote` | Selected quote metadata reaches the platform. | +| `messageSendingHooks` | Core message-sending hooks can cancel or rewrite content before platform I/O. | +| `batch` | Multi-part rendered batches are replayable as one durable plan. | +| `reconcileUnknownSend` | The adapter can resolve `unknown_after_send` recovery without blind replay. | +| `afterSendSuccess` | Channel-local after-send side effects run once. | +| `afterCommit` | Channel-local after-commit side effects run once. | + +Best-effort final delivery does not require `reconcileUnknownSend`; it uses the +shared lifecycle when the adapter preserves the payload's visible semantics, and +falls back to direct platform I/O if queue persistence is unavailable. Required +durable final delivery must explicitly require `reconcileUnknownSend`. If the +adapter cannot determine whether a started/unknown send reached the platform, +do not declare that capability; core will reject required durable delivery +before queueing. + +When a caller needs durable delivery, derive requirements instead of building +maps by hand: + +```typescript +import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-message"; + +const requiredCapabilities = deriveDurableFinalDeliveryRequirements({ + payload, + replyToId, + threadId, + silent, + payloadTransport: true, + extraCapabilities: { + nativeQuote: hasSelectedQuote(payload), + }, +}); +``` + +`messageSendingHooks` is required by default. Set `messageSendingHooks: false` +only for a path that intentionally cannot run global message-sending hooks. + +## Durable Send Contract + +A durable final send has stricter semantics than legacy channel-owned delivery: + +- Create the durable intent before platform I/O. +- If durable delivery returns a handled result, do not fall back to legacy send. +- Treat hook cancellation and no-send results as terminal. +- Treat `unsupported` as a pre-intent result only. +- For required durability, fail before platform I/O if the queue cannot record + that platform send has started. +- For required final delivery and required prepared message-tool sends, + preflight `reconcileUnknownSend`; recovery must be able to ack an + already-sent message or replay only after the adapter proves the original send + did not happen. +- For `best_effort`, queue write failures may fall back to direct platform I/O. +- Forward abort signals to media loading and platform sends. +- Run after-commit hooks after queue ack; direct best-effort fallback runs them + after successful platform I/O because there is no durable queue commit. +- Return receipts for every visible platform message id. +- Use `reconcileUnknownSend` when a platform can check whether an uncertain send + already reached the user. + +This contract avoids duplicate sends after crashes and avoids bypassing +message-sending cancellation hooks. + +## Receipts + +`MessageReceipt` is the new internal record of what the platform accepted: + +```typescript +type MessageReceipt = { + primaryPlatformMessageId?: string; + platformMessageIds: string[]; + parts: MessageReceiptPart[]; + threadId?: string; + replyToId?: string; + editToken?: string; + deleteToken?: string; + sentAt: number; + raw?: readonly MessageReceiptSourceResult[]; +}; +``` + +Use `createMessageReceiptFromOutboundResults(...)` when adapting an existing +send result. Use `createPreviewMessageReceipt(...)` when a live preview message +becomes the final receipt. Avoid adding new owner-local `messageIds` fields. +Legacy `ChannelDeliveryResult.messageIds` is still produced at compatibility +edges. + +## Live Preview + +Channels that stream draft previews or progress updates should declare live +capabilities: + +```typescript +const demoMessageAdapter = defineChannelMessageAdapter({ + id: "demo", + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + discardPending: true, + previewReceipt: true, + retainOnAmbiguousFailure: true, + }, + }, + }, +}); +``` + +Use `defineFinalizableLivePreviewAdapter(...)` and +`deliverWithFinalizableLivePreviewAdapter(...)` for runtime finalization. The +finalizer decides whether the final reply edits the preview in place, sends a +normal fallback, discards pending preview state, keeps an ambiguous failed edit +without duplicating the message, and returns the final receipt. + +## Receive Ack Policy + +Inbound receivers that control platform acknowledgement timing should declare +receive policy: + +```typescript +const demoMessageAdapter = defineChannelMessageAdapter({ + id: "demo", + receive: { + defaultAckPolicy: "after_agent_dispatch", + supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"], + }, +}); +``` + +Adapters that do not declare receive policy default to: + +```typescript +{ + receive: { + defaultAckPolicy: "manual", + supportedAckPolicies: ["manual"], + }, +} +``` + +Use the default when the platform has no acknowledgement to defer, already +acknowledges before asynchronous processing, or needs protocol-specific response +semantics. Declare one of the staged policies only when the receiver actually +uses receive context to move platform acknowledgement later. + +Policies: + +| Policy | Use when | +| ---------------------- | ---------------------------------------------------------------------------------------- | +| `after_receive_record` | The platform can be acknowledged after the inbound event is parsed and recorded. | +| `after_agent_dispatch` | The platform should wait until the agent dispatch has been accepted. | +| `after_durable_send` | The platform should wait until final delivery has a durable decision. | +| `manual` | The plugin owns acknowledgement because platform semantics do not match a generic stage. | + +Use `createMessageReceiveContext(...)` in receivers that defer ack state, and +`shouldAckMessageAfterStage(...)` when the receiver needs to test whether a +stage has satisfied the configured policy. + +## Contract Tests + +Capability declarations are part of the plugin contract. Back them with tests: + +```typescript +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, + verifyChannelMessageReceiveAckPolicyAdapterProofs, +} from "openclaw/plugin-sdk/channel-message"; + +it("backs declared message capabilities", async () => { + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "demo", + adapter: demoMessageAdapter, + proofs: { + text: async () => { + const result = await demoMessageAdapter.send!.text!(textCtx); + expect(result.receipt.platformMessageIds).toContain("msg-1"); + }, + replyTo: async () => { + await demoMessageAdapter.send!.text!({ ...textCtx, replyToId: "parent-1" }); + expect(sendDemoMessage).toHaveBeenCalledWith( + expect.objectContaining({ + replyToId: "parent-1", + }), + ); + }, + messageSendingHooks: () => { + expect(demoMessageAdapter.durableFinal!.capabilities!.messageSendingHooks).toBe(true); + }, + }, + }), + ).resolves.toContainEqual({ capability: "text", status: "verified" }); +}); +``` + +Add live and receive proof suites when the adapter declares those features. A +missing proof should fail the test rather than silently widening the durable +surface. + +## Deprecated Compatibility APIs + +These APIs remain importable for third-party compatibility. Do not use them for +new channel code. + +| Deprecated API | Replacement | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `openclaw/plugin-sdk/channel-reply-pipeline` | `openclaw/plugin-sdk/channel-message` | +| `createChannelTurnReplyPipeline(...)` | `createChannelMessageReplyPipeline(...)` for compatibility dispatchers, or a `message` adapter for new channel code | +| `deliverDurableInboundReplyPayload(...)` | `deliverInboundReplyWithMessageSendContext(...)` from `openclaw/plugin-sdk/channel-message-runtime` | +| `dispatchInboundReplyWithBase(...)` | `dispatchChannelMessageReplyWithBase(...)` only for compatibility dispatchers | +| `recordInboundSessionAndDispatchReply(...)` | `recordChannelMessageReplyDispatch(...)` only for compatibility dispatchers | +| `resolveChannelSourceReplyDeliveryMode(...)` | `resolveChannelMessageSourceReplyDeliveryMode(...)` | +| `deliverFinalizableDraftPreview(...)` | `defineFinalizableLivePreviewAdapter(...)` plus `deliverWithFinalizableLivePreviewAdapter(...)` | +| `DraftPreviewFinalizerDraft` | `LivePreviewFinalizerDraft` | +| `DraftPreviewFinalizerResult` | `LivePreviewFinalizerResult` | + +Compatibility dispatchers can still use `createReplyPrefixContext(...)`, +`createReplyPrefixOptions(...)`, and `createTypingCallbacks(...)` through the +message facade. New lifecycle code should avoid the old +`channel-reply-pipeline` subpath. + +## Migration Checklist + +1. Add `message: defineChannelMessageAdapter(...)` or + `message: createChannelMessageAdapterFromOutbound(...)` to the channel plugin. +2. Return `MessageReceipt` from text, media, and payload sends. +3. Declare only capabilities backed by native behavior and tests. +4. Replace hand-written durable requirement maps with + `deriveDurableFinalDeliveryRequirements(...)`. +5. Move preview finalization through the live preview helpers when the channel + edits draft messages in place. +6. Declare receive ack policy only when the receiver can really defer platform + acknowledgement. +7. Keep legacy reply dispatch helpers only at compatibility edges. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index f645d2203d9..e7c4aa61e95 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -34,6 +34,46 @@ shared `message` tool in core. Your plugin owns: Core owns the shared message tool, prompt wiring, the outer session-key shape, generic `:thread:` bookkeeping, and dispatch. +New channel plugins should also expose a `message` adapter with +`defineChannelMessageAdapter` from `openclaw/plugin-sdk/channel-message`. The +adapter declares which durable final-send capabilities the native transport +actually supports and points text/media sends at the same transport functions as +the legacy `outbound` adapter. Only declare a capability when a contract test +proves the native side effect and returned receipt. +For the full API contract, examples, capability matrix, receipt rules, live +preview finalization, receive ack policy, tests, and migration table, see +[Channel message API](/plugins/sdk-channel-message). +If the existing `outbound` adapter already has the right send methods and +capability metadata, use `createChannelMessageAdapterFromOutbound(...)` to +derive the `message` adapter instead of hand-writing another bridge. +Adapter sends should return `MessageReceipt` values. When compatibility code +still needs legacy ids, derive them with `listMessageReceiptPlatformIds(...)` +or `resolveMessageReceiptPrimaryId(...)` instead of keeping parallel +`messageIds` fields in new lifecycle code. +Preview-capable channels should also declare `message.live.capabilities` with +the exact live lifecycle they own, such as `draftPreview`, +`previewFinalization`, `progressUpdates`, `nativeStreaming`, or +`quietFinalization`. Channels that finalize a draft preview in place should +also declare `message.live.finalizer.capabilities`, such as `finalEdit`, +`normalFallback`, `discardPending`, `previewReceipt`, and +`retainOnAmbiguousFailure`, and route the runtime logic through +`defineFinalizableLivePreviewAdapter(...)` plus +`deliverWithFinalizableLivePreviewAdapter(...)`. Keep those capabilities backed +by `verifyChannelMessageLiveCapabilityAdapterProofs(...)` and +`verifyChannelMessageLiveFinalizerProofs(...)` tests so native preview, +progress, edit, fallback/retention, cleanup, and receipt behavior cannot drift +silently. +Inbound receivers that defer platform acknowledgements should declare +`message.receive.defaultAckPolicy` and `supportedAckPolicies` instead of hiding +ack timing in monitor-local state. Cover every declared policy with +`verifyChannelMessageReceiveAckPolicyAdapterProofs(...)`. + +Legacy reply/turn helpers such as `createChannelTurnReplyPipeline`, +`dispatchInboundReplyWithBase`, and `recordInboundSessionAndDispatchReply` +remain available for compatibility dispatchers. Do not use those names for new +channel code; new plugins should start with the `message` adapter, receipts, and +receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`. + If your channel supports typing indicators outside inbound replies, expose `heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the resolved heartbeat delivery target before the heartbeat model run starts and @@ -50,6 +90,13 @@ Prefer returning an action-keyed map such as inherit another action's media args. A flat array still works for params that are intentionally shared across every exposed action. +If your channel needs provider-specific shaping for `message(action="send")`, +prefer `actions.prepareSendPayload(...)`. Put native cards, blocks, embeds, or +other durable data under `payload.channelData.` and let core perform +the actual send through the outbound/message adapter. Use +`actions.handleAction(...)` for send only as a compatibility fallback for +payloads that cannot be serialized and retried. + If your platform stores extra scope inside conversation ids, keep that parsing in the plugin with `messaging.resolveSessionConversation(...)`. That is the canonical hook for mapping `rawId` to the base conversation id, optional thread diff --git a/docs/plugins/sdk-channel-turn.md b/docs/plugins/sdk-channel-turn.md index 97cca37cd97..c8b32d1cc87 100644 --- a/docs/plugins/sdk-channel-turn.md +++ b/docs/plugins/sdk-channel-turn.md @@ -312,17 +312,23 @@ The kernel does not call the platform directly. The channel hands the kernel a ` type ChannelTurnDeliveryAdapter = { deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise; onError?(err: unknown, info: { kind: string }): void; + durable?: false | DurableInboundReplyDeliveryOptions; }; type ChannelDeliveryResult = { messageIds?: string[]; + receipt?: MessageReceipt; threadId?: string; replyToId?: string; visibleReplySent?: boolean; }; ``` -`deliver` is called once per buffered reply chunk. Return platform message ids when the channel has them so the dispatcher can preserve thread anchors and edit later chunks. For observe-only turns, return `{ visibleReplySent: false }` or use `createNoopChannelTurnDeliveryAdapter()`. +`deliver` is called once per buffered reply chunk. During the message-lifecycle migration, assembled channel-turn delivery is channel-owned by default: an omitted `durable` field means the kernel must call `deliver` directly and must not route through generic outbound delivery. Set `durable` only after the channel has been audited to prove the generic send path preserves the old delivery behavior, including reply/thread targets, media handling, sent-message/self-echo caches, status cleanup, and returned message ids. `durable: false` remains a compatibility spelling for "use the channel-owned callback", but unmigrated channels should not need to add it. Return platform message ids when the channel has them so the dispatcher can preserve thread anchors and edit later chunks; newer delivery paths should also return `receipt` so recovery, preview finalization, and duplicate suppression can move off `messageIds`. For observe-only turns, return `{ visibleReplySent: false }` or use `createNoopChannelTurnDeliveryAdapter()`. + +Channels using `runPrepared` with a fully channel-owned dispatcher do not have a `ChannelTurnDeliveryAdapter`. Those dispatchers are not durable by default. They should keep their direct delivery path until they explicitly opt in to the new send context with a complete target, replay-safe adapter, receipt contract, and channel side-effect hooks. + +Public compatibility helpers such as `recordInboundSessionAndDispatchReply`, `dispatchInboundReplyWithBase`, and direct-DM helpers must stay behavior-preserving during migration. They should not call generic durable delivery before caller-owned `deliver` or `reply` callbacks. ## Record options @@ -388,6 +394,7 @@ Backward compatibility rules apply: new fact fields are additive, admission kind ## Related +- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) for the planned send/receive/live lifecycle that will wrap this kernel - [Building channel plugins](/plugins/sdk-channel-plugins) for the broader channel plugin contract - [Plugin runtime helpers](/plugins/sdk-runtime) for other `runtime.*` surfaces - [Plugin internals](/plugins/architecture-internals) for load pipeline and registry mechanics diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index bf267c7dc48..abb522b3e9f 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -56,7 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers | | `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers | | `plugin-sdk/channel-pairing` | `createChannelPairingController` | - | `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` | + | `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. | | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` | | `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders | | `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only | @@ -64,9 +64,11 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback | | `plugin-sdk/command-gating` | Narrow command authorization gate helpers | | `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` | - | `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, draft stream lifecycle/finalization helpers | + | `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. | + | `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, compatibility facades, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy `createChannelTurnReplyPipeline` remains only for compatibility dispatchers. | + | `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, `withDurableMessageSendContext`, `dispatchChannelMessageReplyWithBase`, and `recordChannelMessageReplyDispatch`. Use from monitor/send runtime modules, not hot plugin bootstrap files. | | `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers | - | `plugin-sdk/inbound-reply-dispatch` | Shared inbound record-and-dispatch helpers | + | `plugin-sdk/inbound-reply-dispatch` | Legacy shared inbound record-and-dispatch helpers, visible/final dispatch predicates, and deprecated `deliverDurableInboundReplyPayload` compatibility for prepared channel dispatchers. New channel receive/dispatch code should import runtime lifecycle helpers from `plugin-sdk/channel-message-runtime`. | | `plugin-sdk/messaging-targets` | Target parsing/matching helpers | | `plugin-sdk/outbound-media` | Shared outbound media loading helpers | | `plugin-sdk/outbound-send-deps` | Lightweight outbound send dependency lookup for channel adapters |