Appearance
OpenClaw 消息生命周期重构将分散的渠道 turn、回复分发、预览流整合为统一的持久化消息生命周期。核心将 receive 和 send 作为基本原语,回复仅作为出站消息的关系。提供接收上下文、发送上下文、实时上下文,确保出站意图持久化后再发送平台,重启后可恢复。适用于重构渠道发送/接收行为、设计新渠道插件或需要可靠发送的场景。
OpenClaw 消息生命周期重构:持久化发送与接收上下文设计
当前页是替换分散的渠道 turn、回复分发、预览流和出站发送辅助工具的目标设计,转为统一持久的消息生命周期。
简短版本:
- 核心原语应为 receive 和 send,而不是 reply。
- 回复仅是出站消息的一个关系。
- turn 是入站处理的便利,不是发送行为的所有者。
- 发送必须基于上下文:begin、render、preview 或 stream、final send、commit、fail。
- 接收也必须基于上下文:normalize、dedupe、route、record、dispatch、platform ack、fail。
- 公共插件 SDK 应缩减为一个小的渠道消息接口。
当前问题
当前渠道栈是根据几个本地需求逐步成长的:
- 简单入站适配器使用
runtime.channel.turn.run。 - 丰富适配器使用
runtime.channel.turn.runPrepared。 - 旧辅助工具使用
dispatchInboundReplyWithBase、recordInboundSessionAndDispatchReply、回复负载辅助工具、回复拆分、回复引用和出站运行时辅助工具。 - 预览流驻留在渠道特定的分发器中。
- 最终发送的持久化正在围绕现有回复负载路径添加。
这种形状修补了局部错误,但给 OpenClaw 留下了太多公共概念和太多发送语义可能漂移的地方。
暴露可靠性问题的场景:
text
Telegram 轮询更新已确认
-> 助手最终文本已存在
-> 进程在 sendMessage 成功前重启
-> 最终响应丢失目标不变量比 Telegram 更广:一旦核心决定应存在可见的出站消息,该意图必须在平台发送尝试之前持久化,且平台接收确认必须在成功后提交。这使 OpenClaw 获得至少一次恢复。恰好一次行为仅适用于能证明原生幂等性或通过平台状态协调未知发送后尝试的适配器。
这是重构的最终状态,不是每个当前路径的描述。迁移期间,现有出站辅助工具在尽力写入队列失败时仍可回退到直接发送。重构只有在持久化最终发送失败关闭或通过记录的 non-durable 策略显式退出后才算完成。
目标
- 一个核心生命周期,用于所有渠道消息接收和发送路径。
- 在适配器声明 replay-safe 后,默认使用新的消息生命周期进行持久化最终发送。
- 共享预览、编辑、流、最终化、重试、恢复和接收确认语义。
- 一个小型插件 SDK 接口,第三方插件可学习和维护。
- 迁移期间与现有
channel.turn调用者兼容。 - 为新渠道能力提供清晰的扩展点。
- 核心中无平台特定分支。
- 无 token-delta 渠道消息。渠道流仍为消息预览、编辑、追加或已完成的块发送。
- 结构化 OpenClaw 来源元数据,用于操作/系统输出,使可见的网关失败不会以新的提示方式重新进入共享的机器人启用房间。
非目标
- 第一阶段不移除
runtime.channel.turn.*。 - 不强制每个渠道采用相同的原生传输行为。
- 不教核心 Telegram 话题、Slack 原生流、Matrix 删除、飞书卡片、QQ 语音或 Teams 活动。
- 不将所有内部迁移辅助工具作为稳定 SDK API 发布。
- 重试时不重复已完成的非幂等平台操作。
参考模型
Vercel Chat 有一个好的公共心智模型:
ChatThreadChannelMessage- 适配器方法如
postMessage、editMessage、deleteMessage、stream、startTyping和历史获取 - 用于去重、锁、队列和持久化的状态适配器
OpenClaw 应借用其词汇,而不是复制其接口。
OpenClaw 在该模型之外的需要:
- 在直接传输调用之前持久化出站发送意图。
- 显式的发送上下文,包含 begin、commit 和 fail。
- 知道平台确认策略的接收上下文。
- 重启后仍存活的接收确认,可驱动编辑、删除、恢复和重复抑制。
- 更小的公共 SDK。捆绑插件可使用内部运行时辅助工具,但第三方插件应看到一个连贯的消息 API。
- 智能体特定行为:会话、转录、块流、工具进度、审批、媒体指令、静默回复和群组提及历史。
thread.post() 风格 promises 对 OpenClaw 不够。它们隐藏了决定发送是否可恢复的事务边界。
核心模型
新领域应位于内部核心命名空间下,例如 src/channels/message/*。
包含四个概念:
typescript
core.messages.receive(...)
core.messages.send(...)
core.messages.live(...)
core.messages.state(...)receive 拥有入站生命周期。
send 拥有出站生命周期。
live 拥有预览、编辑、进度和流状态。
state 拥有持久化意图存储、接收确认、幂等性、恢复、锁和去重。
消息术语
Message
标准化消息是平台中立的:
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
Target 描述消息所在位置:
typescript
type MessageTarget = {
kind: "direct" | "group" | "channel" | "thread";
id: string;
label?: string;
spaceId?: string;
parentId?: string;
threadId?: string;
nativeChannelId?: string;
};Relation
回复是一个关系,不是 API 根:
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";
};这使同一发送路径可处理正常回复、cron 通知、审批提示、任务完成、消息工具发送、CLI 或 Control UI 发送、子智能体结果和自动化发送。
Origin
Origin 描述谁产生了消息以及 OpenClaw 应如何处理该消息的回显。它与 relation 分离:一条消息可以是对用户的回复,同时仍是 OpenClaw 起源的操作性输出。
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";
};核心拥有 OpenClaw 起源输出的含义。渠道负责将该 origin 编码到其传输中。
第一个必要用途是网关失败输出。人类仍应看到类似“Agent failed before reply”或“Missing API key”的消息,但标记为 OpenClaw 操作输出的消息在启用 allowBots 的共享房间中不得作为机器人作者输入接受。
Receipt
Receipt 是一等公民:
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;
};Receipt 是从持久化意图到未来编辑、删除、预览最终化、重复抑制和恢复的桥梁。
一个 receipt 可描述一条平台消息或多部分发送。分块文本、媒体加文本、语音加文本和卡片回退必须保留所有平台 ID,同时暴露一个用于线程和后续编辑的主 ID。
接收上下文
接收不应是一个简单的辅助调用。核心需要一个知道去重、路由、会话记录和平台确认策略的上下文。
typescript
type MessageReceiveContext = {
id: string;
channel: string;
accountId?: string;
input: ChannelMessage;
ack: ReceiveAckController;
route: MessageRouteController;
session: MessageSessionController;
log: MessageLifecycleLogger;
dedupe(): Promise<ReceiveDedupeResult>;
resolve(): Promise<ResolvedInboundMessage>;
record(resolved: ResolvedInboundMessage): Promise<RecordResult>;
dispatch(recorded: RecordResult): Promise<DispatchResult>;
commit(result: DispatchResult): Promise<void>;
fail(error: unknown): Promise<void>;
};接收流程:
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 allowsAck 不是同一件事。接收契约必须区分以下信号:
- Transport ack: 告知平台 webhook 或 socket OpenClaw 已接受事件信封。某些平台要求在进行分发前确认。
- Polling offset ack: 推进游标,以免再次获取相同事件。此确认不得推进到无法恢复的工作。
- Inbound record ack: 确认 OpenClaw 已持久化足够入站元数据以去重和路由重新投递。
- User-visible receipt: 可选的读回执/状态/输入行为;绝不是一个持久化边界。
ReceiveAckPolicy 仅控制传输或轮询确认。不得将其重用于读取回执或状态反应。
在 bot 授权之前,当渠道可解码消息来源元数据时,接收必须应用共享的 OpenClaw 回显策略:
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"
);
}此丢弃基于标签,而不是基于文本。具有相同可见网关失败文本但无 OpenClaw 来源元数据的机器人作者房间消息仍通过正常 allowBots 授权。
确认策略是显式的:
typescript
type ReceiveAckPolicy =
| { kind: "immediate"; reason: "webhook-timeout" | "platform-contract" }
| { kind: "after-record" }
| { kind: "after-durable-send" }
| { kind: "manual" };Telegram 轮询现在使用接收上下文的 ack 策略来持久化其重启水印。跟踪器仍观察 grammY 更新进入中间件链的顺序,但 OpenClaw 仅在成功分发后持久化安全的已完成更新 ID,将失败或挂起的较低更新留在重启后可重放。Telegram 上游的 getUpdates 获取偏移仍由轮询库控制,因此如果我们需要超出 OpenClaw 重启水印的平台级重新投递,剩余的深层改动是完全持久的轮询源。webhook 平台可能需要立即 HTTP 确认,但它们仍然需要入站去重和持久化出站发送意图,因为 webhook 可能重新投递。
发送上下文
发送也是基于上下文的:
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<RenderedMessageBatch>;
previewUpdate(rendered: RenderedMessageBatch): Promise<LiveMessageState>;
send(rendered: RenderedMessageBatch): Promise<MessageReceipt>;
edit(receipt: MessageReceipt, rendered: RenderedMessageBatch): Promise<MessageReceipt>;
delete(receipt: MessageReceipt): Promise<void>;
commit(receipt: MessageReceipt): Promise<void>;
fail(error: unknown): Promise<void>;
};推荐的编排:
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);
});该辅助工具展开为:
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意图必须在传输 I/O 之前存在。在 begin 之后但在 commit 之前重启是可恢复的。
危险边界是平台成功之后到 receipt 提交之前。如果进程在那里死亡,OpenClaw 无法知道平台消息是否存在,除非适配器提供原生幂等性或 receipt 协调路径。这些尝试必须在 unknown_after_send 中恢复,而不是盲目重放。没有协调的渠道可能选择至少一次重放,仅当重复可见消息对于该渠道和关系是可接受的、已记录的折衷。当前 SDK 协调桥要求适配器声明 reconcileUnknownSend,然后询问 durableFinal.reconcileUnknownSend 将未知条目分类为 sent、not_sent 或 unresolved;只有 not_sent 允许重放,未解决的条目保持终端状态或仅重试协调检查。
持久化策略必须显式:
typescript
type MessageDurabilityPolicy = "required" | "best_effort" | "disabled";required 意味着当核心无法写入持久化意图时必须失败关闭。best_effort 可在持久化不可用时回退。disabled 保持旧有的直接发送行为。迁移期间,旧包装器和公共兼容性辅助工具默认使用 disabled;它们不得因为渠道有通用出站适配器而推断 required。
发送上下文也拥有渠道本地的发送后效果。如果持久化发送绕过之前附加到渠道直接发送路径的本地行为,则迁移不安全。示例包括自回显抑制缓存、线程参与标记、原生编辑锚点、模型签名渲染和平台特定的重复防护。这些效果必须要么移入发送适配器、渲染适配器,要么在启用该渠道的持久化通用最终发送之前移入命名的发送上下文钩子。
发送辅助工具必须将 receipt 一路返回到其调用者。持久化包装器不得吞没消息 ID 或将渠道发送结果替换为 undefined;缓冲分发器使用这些 ID 进行线程锚点、后续编辑、预览最终化和重复抑制。
回退发送基于批次操作,而不是单一负载。静默回复重写、媒体回退、卡片回退和块投影可能产生多个可投递消息,因此发送上下文要么交付整个投影批次,要么显式记录为何只有一个负载有效。
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;
};当此类回退是持久化的时,整个投影批次必须由一个持久化发送意图或其他原子批次计划表示。逐个记录每个负载是不够的:负载之间的崩溃可能留下部分可见的回退,而无剩余负载的持久化记录。恢复必须知道哪些单元已有 receipt,并要么仅重放缺失的单元,要么将批次标记为 unknown_after_send 直到适配器协调它。
实时上下文
预览、编辑、进度和流行为应是一个可选的生命周期。
typescript
type MessageLiveAdapter = {
begin?(ctx: MessageSendContext): Promise<LiveMessageState>;
update?(
ctx: MessageSendContext,
state: LiveMessageState,
update: LiveMessageUpdate,
): Promise<LiveMessageState>;
finalize?(
ctx: MessageSendContext,
state: LiveMessageState,
final: RenderedMessageBatch,
): Promise<MessageReceipt>;
cancel?(
ctx: MessageSendContext,
state: LiveMessageState,
reason: LiveCancelReason,
): Promise<void>;
};实时状态足够持久化以恢复或抑制重复:
typescript
type LiveMessageState = {
mode: "partial" | "block" | "progress" | "native";
receipt?: MessageReceipt;
visibleSince?: number;
canFinalizeInPlace: boolean;
lastRenderedHash?: string;
staleAfterMs?: number;
};这应覆盖当前行为:
- Telegram 发送加编辑预览,在预览过时后发送新的最终消息。
- Discord 发送加编辑预览,媒体/错误/显式回复时取消。
- Slack 根据线程形状选择原生流或草稿预览。
- Mattermost 草稿后最终化。
- Matrix 草稿事件最终化或在匹配错误时删除。
- Teams 原生进度流。
- QQ Bot 流或累积回退。
适配器接口
公共 SDK 目标应是一个子路径:
typescript
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message";目标形状:
typescript
type ChannelMessageAdapter = {
receive?: MessageReceiveAdapter;
send: MessageSendAdapter;
live?: MessageLiveAdapter;
origin?: MessageOriginAdapter;
render?: MessageRenderAdapter;
capabilities: MessageCapabilities;
};发送适配器:
typescript
type MessageSendAdapter = {
send(ctx: MessageSendContext, rendered: RenderedMessageBatch): Promise<MessageReceipt>;
edit?(
ctx: MessageSendContext,
receipt: MessageReceipt,
rendered: RenderedMessageBatch,
): Promise<MessageReceipt>;
delete?(ctx: MessageSendContext, receipt: MessageReceipt): Promise<void>;
classifyError?(ctx: MessageSendContext, error: unknown): DeliveryFailureKind;
reconcileUnknownSend?(ctx: MessageSendContext): Promise<MessageReceipt | null>;
afterSendSuccess?(ctx: MessageSendContext, receipt: MessageReceipt): Promise<void>;
afterCommit?(ctx: MessageSendContext, receipt: MessageReceipt): Promise<void>;
};接收适配器:
typescript
type MessageReceiveAdapter<TRaw = unknown> = {
normalize(raw: TRaw, ctx: MessageNormalizeContext): Promise<ChannelMessage>;
classify?(message: ChannelMessage): Promise<MessageEventClass>;
preflight?(message: ChannelMessage, event: MessageEventClass): Promise<MessagePreflightResult>;
ackPolicy?(message: ChannelMessage, event: MessageEventClass): ReceiveAckPolicy;
};在预飞行授权之前,每当 origin.decode 返回 OpenClaw 来源元数据时,核心必须运行共享的 OpenClaw 回显谓词。接收适配器提供平台事实(如机器人作者和房间形状);核心拥有丢弃决策和排序,因此渠道不会重新实现文本过滤器。
来源适配器:
typescript
type MessageOriginAdapter<TRaw = unknown, TNative = unknown> = {
encode?(origin: MessageOrigin): TNative | undefined;
decode?(raw: TRaw): MessageOrigin | undefined;
};核心设置 MessageOrigin。渠道仅将其转换为原生传输元数据或从原生传输元数据转换回来。Slack 映射到 chat.postMessage({ metadata }) 和入站 message.metadata;Matrix 可映射到额外事件内容;没有原生元数据的渠道可在最佳近似时使用 receipt/outbound 注册表。
能力:
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;
};
};公共 SDK 缩减
新的公共接口应吸收或弃用以下概念区域:
reply-runtimereply-dispatch-runtimereply-referencereply-chunkingreply-payloadinbound-reply-dispatchchannel-reply-pipeline- 大多数
outbound-runtime的公共使用 - 临时草稿流生命周期辅助工具
兼容性子路径可作为包装器保留,但新的第三方插件不应需要它们。
捆绑插件在迁移期间可通过保留的运行时子路径保持内部辅助工具导入。公共文档应在 plugin-sdk/channel-message 存在后引导插件作者使用它。
与 channel turn 的关系
迁移期间 runtime.channel.turn.* 应保留。
它应成为一个兼容性适配器:
text
channel.turn.run
-> messages.receive context
-> session dispatch
-> messages.send context for visible outputchannel.turn.runPrepared 也应初始保留:
text
channel-owned dispatcher
-> messages.receive record/finalize bridge
-> messages.live for preview/progress
-> messages.send for final delivery在所有捆绑插件和已知第三方兼容性路径桥接后,channel.turn 可弃用。在发布 SDK 迁移路径和合同测试证明旧插件仍能工作或通过明确的版本错误失败之前,不应移除。
兼容性护栏
迁移期间,对于任何其现有发送回调除“发送此负载”之外还有副作用的渠道,通用持久化发送是 opt-in 的。
旧有入口点默认是非持久化的:
channel.turn.run和dispatchAssembledChannelTurn使用渠道的发送回调,除非该渠道明确提供经过审计的持久化策略/选项对象。channel.turn.runPrepared保持渠道自有,直到准备好的分发器显式调用发送上下文。- 公共兼容性辅助工具如
recordInboundSessionAndDispatchReply、dispatchInboundReplyWithBase和直接 DM 辅助工具永远不会在调用者提供的deliver或reply回调之前注入通用持久化发送。
对于迁移桥接类型,durable: undefined 意味着“非持久化”。持久化路径仅通过显式的策略/选项值启用。durable: false 可作为兼容拼写保留,但实现不应要求每个未迁移的渠道都添加它。
当前桥接代码必须保持持久化决策显式:
- 持久化最终发送返回区分状态。
handled_visible和handled_no_send是终端;unsupported和not_applicable可能回退到渠道自有的发送;failed传播发送失败。 - 通用持久化最终发送由适配器能力(如静默发送、回复目标保留、原生引用保留和消息发送钩子)控制。丢失的奇偶性应选择渠道自有的发送,而不是改变用户可见行为的通用发送。
- 队列支持的持久化发送暴露一个发送意图引用。过渡期间,现有
pendingFinalDelivery*会话字段可携带意图 ID;最终状态是MessageSendIntent存储,而不是冻结的回复文本加临时上下文字段。
在以下所有条件满足之前,不要为渠道启用通用持久化路径:
- 通用发送适配器执行与旧直接路径相同的渲染和传输行为。
- 本地发送后副作用通过发送上下文保留。
- 适配器返回包含所有平台消息 ID 的 receipt 或发送结果。
- 准备好的分发器路径要么调用新的发送上下文,要么保持文档说明在持久化保证之外。
- 回退发送处理每个投影负载,而不仅仅是第一个。
- 持久化回退发送将整个投影负载数组记录为一个可重放的意图或批次计划。
具体迁移风险需保留:
- iMessage 监视器在成功发送后记录已发送消息到回显缓存。持久化最终发送仍必须填充该缓存,否则 OpenClaw 可能将其自己的最终回复重新作为入站用户消息摄入。
- Tlon 在群组回复后附加可选的模型签名并记录参与的线程。通用持久化发送不得绕过这些效果;应将它们移入 Tlon 渲染/发送/最终化适配器,或保持 Tlon 在渠道自有路径上。
- Discord 和其他准备好的分发器已拥有直接的发送和预览行为。在它们的准备好的分发器显式将 finals 路由到发送上下文之前,它们不在组装 turn 的持久化保证覆盖范围内。
- Telegram 静默回退发送必须交付完整的投影负载数组。单负载快捷方式可能在投影后丢弃额外的回退负载。
- LINE、Zalo、Nostr 和其他现有组装/辅助路径可能具有回复令牌处理、媒体代理、已发送消息缓存、加载/状态清理或仅回调目标。在那些语义由发送适配器表示并经测试验证之前,它们保持在渠道自有发送上。
- 直接 DM 辅助工具可能有一个回复回调,该回调是唯一正确的传输目标。通用出站不得从
OriginatingTo或To猜测并跳过该回调。 - OpenClaw 网关失败输出必须对人类可见,但标记为机器人作者的房间回显必须在
allowBots授权之前丢弃。渠道不得使用可见文本前缀过滤器来实现此目的,除非作为短期应急措施;持久化契约是结构化的来源元数据。
内部存储
持久化队列应存储消息发送意图,而不是回复负载。
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;
};恢复循环:
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队列应保留足够身份,以在重启后通过相同的账户、线程、目标、格式化策略和媒体规则进行重放。
失败分类
渠道适配器将传输失败分类为封闭类别:
typescript
type DeliveryFailureKind =
| "transient"
| "rate_limit"
| "auth"
| "permission"
| "not_found"
| "invalid_payload"
| "conflict"
| "cancelled"
| "unknown";核心策略:
- 重试
transient和rate_limit。 - 不重试
invalid_payload,除非存在渲染回退。 - 不重试
auth或permission,直到配置更改。 - 对于
not_found,当渠道声明安全时,让实时最终化从编辑回退到新的发送。 - 对于
conflict,使用 receipt/幂等性规则判断消息是否已存在。 - 适配器可能已完成平台 I/O 但尚未提交 receipt 后的任何错误变为
unknown_after_send,除非适配器能证明平台操作未发生。
渠道映射
| 渠道 | 目标迁移 |
|---|---|
| Telegram | 接收确认策略加持久化最终发送。实时适配器拥有发送加编辑预览、陈旧预览最终发送、话题、引用回复预览跳过、媒体回退和重试后处理。 |
| Discord | 发送适配器包装现有持久化负载发送。实时适配器拥有草稿编辑、进度草稿、媒体/错误预览取消、回复目标保留和消息 ID 接收。审计共享房间中的机器人作者网关失败回显;如果 Discord 无法在正常消息上携带来源元数据,则使用出站注册表或其他原生等效手段。 |
| Slack | 发送适配器处理正常 chat 发布。实时适配器在线程形状支持时选择原生流,否则草稿预览。Receipt 保留线程时间戳。来源适配器将 OpenClaw 网关失败映射到 Slack chat.postMessage.metadata,并在 allowBots 授权之前丢弃标记的机器人房间回显。 |
| 发送适配器拥有文本/媒体发送加持久化最终意图。接收适配器处理群组提及和发送者身份。实时可保持缺席,直到 WhatsApp 有可编辑传输。 | |
| Matrix | 实时适配器拥有草稿事件编辑、最终化、删除、加密媒体约束和回复目标不匹配回退。接收适配器拥有加密事件水化和去重。来源适配器应将 OpenClaw 网关失败来源编码到 Matrix 事件内容中,并在 allowBots 处理之前丢弃配置的机器人房间回显。 |
| Mattermost | 实时适配器拥有一个草稿发布、进度/工具折叠、原地最终化和新发送回退。 |
| Microsoft Teams | 实时适配器拥有原生进度和块流行为。发送适配器拥有活动和附件/卡片接收。 |
| 飞书 | 渲染适配器拥有文本/卡片/原始渲染。实时适配器拥有流式卡片和重复最终抑制。发送适配器拥有评论、话题会话、媒体和语音抑制。 |
| QQ Bot | 实时适配器拥有 C2C 流、累加器超时和回退最终发送。渲染适配器拥有媒体标签和文本转语音。 |
| Signal | 简单接收加发送适配器。无可用的实时适配器,除非 signal-cli 增加可靠编辑支持。 |
| iMessage | 简单接收加发送适配器。iMessage 发送必须保留监视器回显缓存填充,然后持久化 finals 才能绕过监视器发送。 |
| Google Chat | 简单接收加发送适配器,线程关系映射到空间和线程 ID。审计 allowBots=true 房间中标记的 OpenClaw 网关失败回显。 |
| LINE | 简单接收加发送适配器,回复令牌约束建模为目标/关系能力。 |
| Nextcloud Talk | SDK 接收桥加发送适配器。 |
| IRC | 简单接收加发送适配器,无可持久化编辑 receipt。 |
| Nostr | 接收加发送适配器用于加密 DM;receipt 为事件 ID。 |
| QA Channel | 用于接收、发送、实时、重试和恢复行为的合同测试适配器。 |
| Synology Chat | 简单接收加发送适配器。 |
| Tlon | 在启用通用持久化最终发送之前,发送适配器必须保留模型签名渲染和参与线程跟踪。 |
| Twitch | 简单接收加发送适配器,带速率限制分类。 |
| Zalo | 简单接收加发送适配器。 |
| Zalo Personal | 简单接收加发送适配器。 |
迁移计划
阶段 1:内部消息域
- 添加
src/channels/message/*类型,包括消息、目标、关系、来源、receipt、能力、持久化意图、接收上下文、发送上下文、实时上下文和失败类别。 - 将
origin?: MessageOrigin添加到当前回复发送使用的迁移桥负载类型,然后随着重构替换回复负载,将其移至ChannelMessage和渲染消息类型。 - 在适配器和测试证明形状之前保持内部。
- 为状态转换和序列化添加纯单元测试。
阶段 2:持久化发送核心
- 将现有出站队列从回复负载持久化移至持久化消息发送意图。
- 让持久化发送意图携带投影负载数组或批次计划,而不仅仅是单个回复负载。
- 通过兼容性转换保留当前队列恢复行为。
- 让
deliverOutboundPayloads调用messages.send。 - 在适配器声明 replay 安全后,在新的消息生命周期中使最终发送持久化为默认并在无法写入持久化意图时失败关闭。现有 channel-turn 和 SDK 兼容性路径在此期间保持直接发送默认。
- 一致记录 receipt。
- 将 receipt 和发送结果返回到原始分发器调用者,而不是将持久化发送视为终端副作用。
- 通过持久化发送意图保留消息来源,以便恢复、重放和分块发送保留 OpenClaw 操作来源。
阶段 3:Channel Turn 桥
- 在
messages.receive和messages.send之上重新实现channel.turn.run和dispatchAssembledChannelTurn。 - 保持当前事实类型稳定。
- 默认保持遗留行为。只有当其适配器显式选择 replay-safe 持久化策略时,组装 turn 的渠道才成为持久化。
- 保留
durable: false作为兼容性逃生活门,用于最终化原生编辑且尚不能安全重放的路径,但不依赖false标记来保护未迁移的渠道。 - 仅在新消息生命周期中默认组装 turn 的持久化,当渠道映射证明通用发送路径保留了旧渠道发送语义后。
阶段 4:准备好的分发器桥
- 用发送上下文桥替换
deliverDurableInboundReplyPayload。 - 将旧辅助工具保留为包装器。
- 首先移植 Telegram、WhatsApp、Slack、Signal、iMessage 和 Discord,因为它们已有持久化最终工作或更简单的发送路径。
- 将每个准备好的分发器视为未覆盖,直到它显式选择发送上下文。文档和变更日志条目必须说“组装渠道 turn”或命名已迁移的渠道路径,而不是声称所有自动最终回复。
- 保持
recordInboundSessionAndDispatchReply、直接 DM 辅助工具和类似公共兼容性辅助工具的行为不变。它们稍后可暴露显式的发送上下文 opt-in,但在调用者拥有的发送回调之前不得自动尝试通用持久化发送。
阶段 5:统一的实时生命周期
- 使用两个证据适配器构建
messages.live:- Telegram 用于发送加编辑加陈旧最终发送。
- Matrix 用于草稿最终化加删除回退。
- 然后迁移 Discord、Slack、Mattermost、Teams、QQ Bot 和飞书。
- 仅在每个渠道有奇偶测试后删除重复的预览最终化代码。
阶段 6:公共 SDK
- 添加
openclaw/plugin-sdk/channel-message。 - 将其记录为首选渠道插件 API。
- 更新包导出、入口点清单、生成的 API 基线和插件 SDK 文档。
- 在渠道消息 SDK 接口中包含
MessageOrigin、来源编解码钩子和共享的shouldDropOpenClawEcho谓词。 - 保持旧子路径的兼容包装器。
- 在捆绑插件迁移后,在文档中将回复命名的 SDK 辅助工具标记为弃用。
阶段 7:所有发送者
将所有非回复的出站生产者移到 messages.send:
- cron 和心跳通知
- 任务完成
- 钩子结果
- 审批提示和审批结果
- 消息工具发送
- 子智能体完成公告
- 显式 CLI 或 Control UI 发送
- 自动化/广播路径
这就是模型不再是“智能体回复”而变成“OpenClaw 发送消息”的地方。
阶段 8:弃用 Turn
- 至少在一个兼容窗口内将
channel.turn保留为包装器。 - 发布迁移说明。
- 对旧导入运行插件 SDK 兼容性测试。
- 仅在无捆绑插件需要它们且第三方契约有稳定替代品后移除或隐藏旧内部辅助工具。
测试计划
单元测试:
- 持久化发送意图序列化和恢复。
- 幂等性键重用和重复抑制。
- Receipt 提交和重放跳过。
unknown_after_send恢复,在适配器支持协调时先协调再重放。- 失败分类策略。
- 接收确认策略排序。
- 回复、后续、系统和广播发送的关系映射。
- 网关失败来源工厂和
shouldDropOpenClawEcho谓词。 - 来源在负载规范化、分块、持久化队列序列化和恢复中的保留。
集成测试:
channel.turn.run简单适配器仍记录并发送。- 旧组装事件发送不会变为持久化,除非渠道显式 opt-in。
channel.turn.runPrepared桥仍记录并最终化。- 公共兼容性辅助工具默认调用调用者拥有的发送回调,并在这些回调之前不进行通用发送。
- 持久化回退发送在重启后重放整个投影负载数组,不能在早期崩溃后留下未记录的后续负载。
- 持久化组装事件发送将平台消息 ID 返回给缓冲分发器。
- 自定义发送钩子在持久化发送禁用或不可用时仍返回平台消息 ID。
- 最终回复在助手完成和平台发送之间重启后幸存。
- 预览草稿在允许时原地最终化。
- 预览草稿在媒体/错误/回复目标不匹配需要正常发送时取消或删除。
- 块流和预览流不都发送相同文本。
- 早期流式媒体不在最终发送中重复。
渠道测试:
- Telegram 话题回复在接收上下文的安全完成水印之前轮询确认延迟。
- Telegram 轮询恢复对于已接受但未投递的更新覆盖持久化的安全完成偏移模型。
- Telegram 陈旧预览发送新的最终消息并清理预览。
- Telegram 静默回退发送每个投影的回退负载。
- Telegram 静默回退持久化以原子方式记录完整的投影回退数组,而不是每次循环迭代一个单负载持久化意图。
- Discord 预览在媒体/错误/显式回复时取消。
- Discord 准备好的分发器 finals 通过发送上下文路由,然后文档或变更日志声称 Discord 最终回复持久化。
- iMessage 持久化最终发送填充监视器已发送消息回显缓存。
- LINE、Zalo 和 Nostr 遗留发送路径不会被通用持久化发送绕过,直到其适配器奇偶测试存在。
- 直接 DM/Nostr 回调发送保持权威,除非显式迁移到完整的消息目标和 replay-safe 发送适配器。
- Slack 标记的 OpenClaw 网关失败消息在外发时保持可见,标记的机器人房间回显在
allowBots之前丢弃,具有相同可见文本但未标记的机器人消息遵循正常机器人授权。 - Slack 原生流回退到顶级 DM 中的草稿预览。
- Matrix 预览最终化和删除回退。
- Matrix 标记的 OpenClaw 网关失败房间回显来自配置的机器人账户在
allowBots处理之前丢弃。 - Discord 和 Google Chat 共享房间网关失败级联审计覆盖
allowBots模式,然后在声称通用保护之前。 - Mattermost 草稿最终化和新发送回退。
- Teams 原生进度最终化。
- 飞书重复最终抑制。
- QQ Bot 累加器超时回退。
- Tlon 持久化最终发送保留模型签名渲染和参与线程跟踪。
- WhatsApp、Signal、iMessage、Google Chat、LINE、IRC、Nostr、Nextcloud Talk、Synology Chat、Tlon、Twitch、Zalo 和 Zalo Personal 简单持久化最终发送。
验证:
- 开发期间有针对性的 Vitest 文件。
- Testbox 中
pnpm check:changed覆盖完整更改表面。 - 在完成重构或公共 SDK/导出更改后,Testbox 中更广泛的
pnpm check。 - 在移除兼容包装器之前,为至少一个可编辑渠道和一个简单仅发送渠道进行实时或 QA 渠道烟雾测试。
开放问题
- Telegram 是否应最终用完全持久的轮询源替换 grammY runner 源,以控制平台级重新投递,而不仅仅是 OpenClaw 的持久化重启水印。
- 持久化实时预览状态应存储在与最终发送意图相同的队列记录中,还是存储在兄弟实时状态存储中。
plugin-sdk/channel-message发布后兼容包装器保留多长时间。- 第三方插件应直接实现接收适配器,还是仅通过
defineChannelMessageAdapter提供规范化/发送/实时钩子。 - 哪些 receipt 字段安全地暴露在公共 SDK 中,哪些是内部运行时状态。
- 副作用如自回显缓存和参与线程标记应建模为发送上下文钩子、适配器拥有的最终化步骤还是 receipt 订阅者。
- 哪些渠道有原生来源元数据,哪些需要持久化出站注册表,哪些无法提供可靠的跨机器人回显抑制。
接受标准
- 每个捆绑消息渠道通过
messages.send发送最终可见输出。 - 每个入站消息渠道通过
messages.receive或文档化的兼容包装器进入。 - 每个预览/编辑/流渠道使用
messages.live进行草稿状态和最终化。 channel.turn仅是一个包装器。- 回复命名的 SDK 辅助工具是兼容性导出,而非推荐路径。
- 持久化恢复可在重启后重放挂起的最终发送,而不会丢失最终响应或重复已提交的发送;平台结果未知的发送在重放前协调,或记录为至少一次适配器。
- 当持久化意图无法写入时,持久化最终发送失败关闭,除非调用者显式选择了文档化的非持久化模式。
- 遗留 channel-turn 和 SDK 兼容性辅助工具默认使用渠道自有的直接发送;通用持久化发送是显式 opt-in。
- Receipt 保留多部分发送的所有平台消息 ID 和一个用于线程/编辑便利的主 ID。
- 持久化包装器在替换直接发送回调之前保留渠道本地副作用。
- 准备好的分发器不被视为持久化,直到其最终发送路径显式使用发送上下文。
- 回退发送处理每个投影负载。
- 持久化回退发送在一个可重放的意图或批次计划中记录每个投影负载。
- OpenClaw 起源的网关失败输出对人类可见,但标记为机器人作者房间回显在声明支持来源契约的渠道上的机器人授权之前丢弃。
- 文档解释发送、接收、实时、状态、receipt、关系、失败策略、迁移和测试覆盖。
相关
常见问题
OpenClaw 消息发送持久化如何配置?
出站消息发送通过 MessageDurabilityPolicy 控制,支持三种策略:required(失败关闭)、best_effort(持久化不可用时回退到直接发送)、disabled(旧有非持久化行为)。适配器需显式声明 replay-safe 后默认启用 required。
消息接收上下文如何处理平台 ack?
接收上下文使用 ReceiveAckPolicy 决定确认时机:immediate(webhook 超时场景)、after-record(记录后确认)、after-durable-send(持久化发送后确认)、manual(手动确认)。例如 Telegram 轮询使用 after-durable-send 避免丢失未完成的更新。
旧有 channel.turn 是否继续可用?
迁移期间 channel.turn.* 会保留作为兼容适配器,旧插件可继续使用。新插件应使用新的 openclaw/plugin-sdk/channel-message 接口。channel.turn 将在所有捆绑插件迁移后弃用,但不会立即移除。