跳转至

通道出站 API

通道插件通过 openclaw/plugin-sdk/channel-outbound 暴露出站消息行为。使用 openclaw/plugin-sdk/channel-inbound 进行接收/上下文/分发编排。

核心层负责队列、持久性、持久化的入口监视与排空(createChannelIngressMonitor、createChannelIngressDrain 和 openChannelIngressDrain)、通用重试策略、轮次采纳生命周期(turnAdoptionLifecycle / bindIngressLifecycleToReplyOptions)、钩子、回执以及共享的 message 工具。插件层负责原生发送/编辑/删除调用、目标规范化、平台线程处理、选中的引用、通知标志、账户状态、入口检查与负载编码、lane 键、不可重试谓词、可选的 supersede 授权,以及平台特定的副作用。

持久化入口监视器

当通道必须在分发前持久化已接受的传输事件时,请使用 createChannelIngressMonitor(...)。它使用共享的准入、轮询、修剪、分发和关闭生命周期,组合出一个通道入口队列与排空。仅当传输层拥有本质上不同的准入或泵合约时,才使用较低层的 createChannelIngressDrain(...)。

必需的选项如下:

选项 合约
queue 一个 ChannelIngressQueue,或一个打开账户作用域队列的惰性工厂。
inspect(raw, context) 返回稳定的 eventId 和序列化的 laneKey,对于被忽略的事件返回 null。认领时的事实必须与已持久化的 id 和 lane 匹配。
payload 提供负载版本以及正文序列化/反序列化。对于标准 { version, rawEvent } 字符串信封,请使用 storage: "raw-event";或者为现有的通道特定形状提供自定义 encode/decode 回调。createClaimError 用于对无效版本或身份变更进行分类。
deliver(raw, lifecycle, claim) 分发一个已解码事件并接收完整的采纳生命周期。它可以返回 completed、deferred、failed-retryable,或不返回任何内容。
pollIntervalMs 在监视器运行期间调度恢复/排空轮询。
retention 提供修剪节奏以及已完成/失败 TTL 和条目上限。

监视器对准入进行串行化,使得追加退避不会颠倒 lane 顺序。默认的有界追加延迟为 0、100 和 300 ms;耗尽时会拒绝传输回调,而不是分发一个尚未持久化的事件。在认领时,它会解码带版本的负载,重新运行 inspect,并在分发前拒绝 id 或 lane 不匹配的事件。当检查需要异步准备时,设置可选的 inspectAsync(raw, context)。支持的宿主在准入和认领验证中都会优先使用它,而不是 inspect。保留 inspect 作为旧宿主的同步回退;这些旧宿主会忽略伴随的异步检查。两个回调必须推导出相同的身份和 lane。标准的 raw-event 便捷监视器保留其同步检查合约。

异步检查保持在现有准入顺序之内。关闭和 waitForIdle() 会等待已接受的检查及其持久化追加完成,包括在通道拥有独立分发宽限期时挂起的认领检查。认领检查会在分发前重新检查关闭状态和认领取消状态。挂起的认领检查会占用现有的启动槽位,并让 onActivityChange 保持忙碌,直到它们完成或转移到分发阶段。

onDurableAdmission(raw, context) 在每次持久化入队后运行,包括重复项。当且仅当本次准入插入了 (queue_name, event_id) 行时,context.isNew 才为 true。它并不表示认领所有权或最终分发。如果保留策略此前已修剪该行,后续准入可能会再次插入它并报告 isNew: true。

deliver 接收 onAdopted、onDeferred、onAdoptionFinalizing、onFailed、onCancelled、onAbandoned 和 abortSignal。对于分发错误使用 onFailed;对于必须在采纳前显式取消且保留重试计数的场景使用 onCancelled;当未采纳的轮次应消耗一次重试尝试时使用 onAbandoned。在没有显式交接的情况下返回,会将一个终态的无分发事件标记为已采纳。admission 始终为 exclusive。延迟交接会保持认领;而关闭或中止会让未采纳的工作保持可重试。监视器独立于认领结算来跟踪分发,因为采纳可以在通道的分发承诺返回之前对某行进行 tombstone 处理。

一轮,多个持久声明

作为一轮回复多个入站事件的通道,会为每个事件持有一个持久声明,并且每个声明都必须到达终态处置。来自 openclaw/plugin-sdk/channel-ingress-runtime 的 fanInChannelIngressLifecycles(lifecycles) 返回单个 ChannelIngressLifecycle,它会将每个回调扇出到所有声明,并提供 settle、abandon 和 cancel,用于在回调之外完成的路径。按它们被声明的顺序传入事件的 lifecycle;undefined 条目会被跳过,空输入会返回 lifecycle: undefined,因此调用方可以回退到单声明路径而无需分支。采用组合 lifecycle 会采用所有声明,放弃它会将其所有声明返回到各自的重试预算——不会留下半结算的声明。采用按传入声明的顺序执行;如果某个声明的采用被拒绝——当另一个所有者已接管它时,drain 会抛出该错误——该声明及其后面的所有声明都会失败,而已采用的声明保持已采用状态,因此拒绝到达调用方时不会留下任何仍被持有的内容。

仅当一个 agent 轮次确实消费多个声明时使用它。如果通道独立回复每个事件,则继续直接传入该事件的 lifecycle。

启动槽位与延迟

drain.startLimit 限制 drain 一次启动多少个 delivery。正常延迟的 delivery 会保留其槽位,因为在默认 deferredLaneOccupancy: "hold" 下,延迟 delivery 仍会串行化其 lane。声明 deferredLaneOccupancy: "release" 的 drain 会在延迟时让出 lane,因此其延迟 delivery 也会归还启动槽位,受等于 startLimit 的预算限制:打开的 delivery 回调保持在 startLimit 加该预算之内,超过后延迟会保留其槽位。如果没有 release,少量延迟 delivery 就会占住所有槽位并阻塞其他 lane。一次可以挂起多少已移交的延迟工作仍由 drain owner 的语义决定,不受该预算改变。

可选设置包括自定义 append 延迟、用于高级 drain 排序/并发/重试策略的 drain 选项块、外部 abortSignal、时钟、pump 错误报告、stopped 错误工厂以及准入策略。返回的 monitor 暴露 admit、ensureQueueAvailable、start、pause、stop、waitForIdle、isRunning 和 isStopped。当插件拥有的迁移或准备工作必须在队列打开之后、drain 启动之前运行时,使用幂等的 ensureQueueAvailable() 检查。stop 先结算已接受的准入,然后中止并释放 drain,等待 pump 和活跃 delivery,并再次释放以关闭懒创建竞态。

将传输特定的脱敏、原始信封验证、不可重试分类和持久化 payload 形状保留在插件中。Webhook 传输应在 admit 解决后才确认;不可重放传输应暴露持久 append 耗尽,而不是静默分发。

延迟声明心跳

当插件包装 ingress lifecycle 或将其映射到 turnAdoptionLifecycle 时,同时转发 onDeferredHeartbeat 和 deferredHeartbeatIntervalMs。bindIngressLifecycleToReplyOptions(...) 会转发两者。drain 从其采用停滞超时期推导可选节奏;fan-in 使用最短的正有限源节奏。队列仅在其拥有 lifecycle 时续期,并在采用、完成、所有权丢失或回调失败后停止。心跳不会采用或完成声明。

省略节奏的包装器仍然有效,但不会启用周期性续期;其延迟声明仍可能达到采用看门狗超时。插件不得运行使已放弃工作保持存活的独立定时器。

适配器

大多数插件定义一个 message 适配器:

import {
  defineChannelMessageAdapter,
  createMessageReceiptFromOutboundResults,
} from "openclaw/plugin-sdk/channel-outbound";

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,
        }),
      };
    },
  },
});

仅声明原生传输实际保留的能力。使用从此子路径导出的匹配契约 helper 覆盖每个已声明能力:

  • 发送:verifyChannelMessageAdapterCapabilityProofs(...)
  • 持久最终投递:verifyDurableFinalCapabilityProofs(...)
  • 实时预览:verifyChannelMessageLiveCapabilityAdapterProofs(...) 和 verifyChannelMessageLiveFinalizerProofs(...)
  • 接收确认:verifyChannelMessageReceiveAckPolicyAdapterProofs(...)

进度与预览投递所有权

为每个已准入的 reply lifecycle 从 openclaw/plugin-sdk/channel-outbound 创建一个 createLivePreviewLifecycle<TPayload, TId>(options)。它拥有最终投递事实和预览保管。将 provider 操作、线程、授权和原生接受检查保留在 channel 适配器中;不要保留并行的 finalDelivered 或 previewCommitted 标志。

可选的 draft 提供 flush、id、discardPending、clear,以及在受支持时提供 seal。discardPending 必须在等待在途工作之前停止新更新。clear 删除已捕获的 provider 工件;返回 false 表示删除未得到确认。没有可删除预览的原生流应省略 draft,而不是提供无操作。

选项 含义
retainOnError 在已接受的错误最终之后保留预览。默认为 false;使用通道现有的错误展示策略。
cleanupUndelivered 当未投递最终且轮次未失败时,允许清理未使用的预览。默认为 false。
onFinalStarted 在最终投递开始时同步停止进度生产者。
onFinalDelivered 同步观察非错误最终的完成。部分接受不会触发此通知。
onCleanupFailure 报告清理失败,而不替换已接受的投递结果。默认会发出通用警告。

在实际投递边界处调用 deliver({ kind, payload, isError, adapter, deliverNormally, onNormalDelivered })。deliverNormally 返回一个 LivePreviewDeliveryResult:即现有通道投递结果,并带有明确的 visibleReplySent 布尔值,以及在可用时提供方的回执或消息 ID。不要将排队或本地缓冲的工作报告为已接受的投递。

对于就地提升,adapter 提供 buildFinalEdit、editFinal,以及任何提供方特定的回执、补充媒体或模糊编辑处理。全新最终传输会省略编辑操作。所有者在观察者和补充投递之前记录提升,因此后续警告无法编辑或删除已提升的答案。

deliver 返回投递类型、实时状态快照以及任何已接受的 deliveryResult。已接受的部分错误会保留其已接受的回执。来自进度刷新的错误不是最终发送的证据。失败或被抑制的最终发送不会触发成功最终清理。显式的补充抑制不会被重试;旧版布尔值 false 补充结果仍然符合正常回退条件。

操作 用途
beginFinalDelivery() 在等待提供方拥有的最终化之前冻结进度。这会记录待投递,而不是接受,并且不会停止原生传输。
observeDelivery(result, { isError }) 通过原生或源/消息工具路径记录提供方确认的最终投递,并退役符合条件的临时进度。不可见结果会被忽略;进度回执不是最终证据。isError 用于区分已接受的错误响应和任务成功,默认为 false。
observeFailure(result?) 记录最终调度器失败。对于部分投递,传入提供方确认的已接受子集;切勿从错误类型推断接受。先前已完成的最终不会被撤销。
observeSuppression() 记录有意的最终不发送决定,例如由出站修饰器钩子取消。它不能隐藏现有投递失败或撤销已接受的内容。
cleanup({ failed }) 使更新静默并清理符合条件的临时预览。失败/部分最终以及保留或已提升的预览仍受保护。清理失败不能授权重发已接受的内容。
retainPreview() 将工件移出自动清理,例如在已接受的延续交接之后。这不声明最终投递。
reset() 开始下一个被接受的轮次、助手回答或块生成。所有者将过期的等待完成与新代隔离。传输仍拥有其对应的消息身份轮换;在助手边界处同时推进两者。

只读属性 finalStarted、finalDelivered、finalSucceeded、finalFailed、finalSuppressed 和 previewFinalized 是该所有者的投影。finalDelivered 表示某些最终内容已被接受,包括部分/错误结果;它不意味着完成。finalSucceeded 要求完整的非错误最终。finalFailed 标识失败或部分投递,而不是其错误消息投递成功的模型错误。保留返回的回执。previewFinalized 还涵盖保留的工件和无法再次提升的已接受替换。

当提供方拥有的分页或延迟最终化无法使用通用 deliver 算法时,使用观察操作。在第一个 await 之前开始,然后在实际结算时报告接受、失败或有意的抑制。缓冲内容和不确定发送不是可见最终证据。

已发布的 defineFinalizableLivePreviewAdapter 和 deliverWithFinalizableLivePreviewAdapter 辅助函数保留其现有 签名和传统“void 表示已投递”约定。它们调用相同的 投递实现,而不是第二个状态机。新集成应 使用有状态所有者和显式结果。这不会更改任何通道配置 或流式默认值。

出站回显抑制

当平台可能将插件自身的出站消息重新投递为入站消息时,请调用 recordOutboundMessageIdentity(...),并传入通道、账户、会话以及稳定的平台消息或来源标识。共享的入站轮次路径会在会话记录或代理分发之前,在一个有界的 30 秒窗口内丢弃匹配的标识;来源标识可以在发送前预留,或在通道路由被移除时刷新,以消除投递竞态。isRecentOutboundMessageIdentity(...) 为通道诊断和测试暴露相同的查询。不要为同一稳定标识维护并行的通道本地 TTL 缓存。

纯文本清理

当出站适配器需要将受支持的 HTML 格式标签转换为轻量级文本标记时,请使用 sanitizeForPlainText(...)。默认保留现有的聊天式粗体和删除线标记。仅当通道会将结果重新解析为 Markdown 时,才传入 { style: "markdown" }:

import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";

const chatText = sanitizeForPlainText(text);
const markdownText = sanitizeForPlainText(text, { style: "markdown" });

Markdown 样式使用 **bold** 和 ~~strikethrough~~;斜体和行内代码在两种样式中都保留 _italic_ 和反引号标记。请在通道边界处选择样式,而不是在清理后重写标记文本。

投递证据

MessageReceipt 记录通道适配器返回的结果。具体的平台消息标识符表明平台发送路径已接受该消息;它们并不能证明接收方设备已显示或已读该消息。聊天、通道、房间、会话或接收方 JID 等目标和路由标识符属于元数据,绝不能作为 platformMessageIds。没有平台消息标识符的收据仅为本地收据元数据。提供方观察到的收据线程会覆盖请求的路由线程。如果批次包含相互冲突的提供方线程,则每个部分保留其线程,且聚合收据省略 threadId。具有已读回执或设备投递状态的通道应通过单独的通道特定路径跟踪这些事实。

当适配器在分发前有意省略发送时,请返回 outcome: "not_sent",并附带空收据且没有消息 ID(传统出站适配器使用空的 messageId)。核心会将 adapter_returned_no_send 记录为有意抑制,不计入任何物理发送,并跳过发送成功和提交钩子。不要将此结果用于已确认但没有平台 ID 的发送,或用于分发后的未知结果。仅凭空收据无法区分这些状态;当省略 outcome 时,现有确认行为保持不变。

如果通道适配器能够证明重试失败不会导致接收方可见的重复发送,并且没有可完成化的调用已开始,请从 openclaw/plugin-sdk/error-runtime 抛出 new PlatformMessageNotDispatchedError("...", { cause: error })。核心随后可以清除过期的发送尝试证据,并安全地重试已排队的意图。只有拥有最终分发边界的适配器才能做出此断言。绝不要在完成化/发送调用开始或返回模糊结果之后使用该标记;错误标记可能导致消息重复。

现有出站适配器

如果通道已经具有兼容的 outbound 适配器,请派生消息适配器,而不是重复发送代码:

import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";

export const messageAdapter = createChannelMessageAdapterFromOutbound({
  id: "demo",
  outbound,
  durableFinal: {
    capabilities: {
      text: true,
      media: true,
    },
  },
});

派生适配器并不会使通道拥有的预置分发器变为持久化。请将其最终发送通过持久化辅助函数路由,同时保留通道特定的发送后效果和仅回调传输目标。message.send.lifecycle.afterSendSuccess 在原生发送成功后运行;对于排队发送,afterCommit 在队列确认后运行。请将效果保留在其所需的边界处,而不是仅留在传统分发器中。

持久化发送

运行时发送辅助函数也位于 channel-outbound:

  • sendDurableMessageBatch(...)
  • withDurableMessageSendContext(...)
  • deliverInboundReplyWithMessageSendContext(...)
  • 草稿流式/进度辅助函数,例如 resolveChannelDraftStreamingChunking(...)

sendDurableMessageBatch(...) 和 withDurableMessageSendContext(...) 默认为 durability: "required":无法持久化发送意图时,会在平台调用之前停止投递。使用 durability: "best_effort" 时,队列写入失败可以回退为已记录的、仅实时发送,而不进行崩溃恢复。这些持久化辅助函数不接受 durability: "disabled"。

sendDurableMessageBatch(...) 返回一个显式结果:

结果 含义
sent 至少一条可见平台消息已被平台发送路径接受
suppressed 不应将任何平台消息视为缺失
partial_failed 在后续有效载荷或副作用失败之前,至少一条平台消息已被接受
failed 未生成任何平台收据
结果 含义

当批次混合了已发送、已抑制和失败的载荷时,使用 payloadOutcomes。不要从空的旧版直接投递结果推断钩子取消。

当 suppressed 结果的原因是 adapter_returned_no_identity,或任何载荷结果记录了发送、无身份发送,或带有 sentBeforeError 的失败时,该结果是歧义的。在将抑制视为有意不投递之前,检查整个批次。对于已知遗漏,动作可以返回 { status: "suppressed", reason: result.reason },而不产生工具错误或伪造回执。保持自由格式钩子诊断私有。抑制不会恢复先前失败的发送。

失败不是通过另一路径发送同一载荷的许可。一旦准入,队列负责重试或对账,直到其确切所有者确认或最终废弃该意图。待处理托管不是投递回执:保留部分回执,并且不要将未确认的 ask_user 提示报告为可见。网关 OUTBOUND_DELIVERY_QUEUED 响应表示投递待处理,且不得重发;不明确的发送可能需要对账,而不是自动重试。

当传输在其首次成功发送期间创建线程时,出站适配器可以实现 adoptTargetFromDelivery(...)。从平台回执返回类型化线程 ID,核心会将其带入该持久批次中的后续载荷、固定和投递后钩子。核心从不替换调用方显式指定的线程,并且如果没有适配器选择加入,也不会从 receipt.threadId 推断采用。

自动未知发送对账

仅当插件能够从持久化的、策略评估后的状态中对账一个不明确的提供商发送,而无需重新运行修改钩子或重新生成提供商载荷时,才设置 message.durableFinal.automaticUnknownSendReconciliation。核心在钩子和取消之后考虑此选择加入,并且仅针对恰好一个已接受的已准备载荷。多载荷批次不会自动选择加入。

适配器还必须声明 capabilities.reconcileUnknownSend: true 并提供 reconcileUnknownSend(...)。使用 reconcileUnknownSendKinds 来命名插件可以证明的具体传输分支,例如 text 或 media。如果类型映射存在,选中的分支必须为 true。省略该映射意味着回调声称每个选中的分支,因此新插件最好使用显式映射。

回调必须使用提供商拥有的幂等性或权威回读,返回带有实际提供商回执的 sent,仅当新发送可证明安全时返回 not_sent,或者当两种结果都无法证明时返回 unresolved。当明确需要对账时,不受支持的已准备形状会在提供商 I/O 之前失败。在恢复期间,缺失、不完整或不匹配的提供商证明必须失败关闭,而不是重放可能已经可见的内容。

如果对账需要提供商拥有的持久化证据,实现 afterUnknownSendTerminal(...)。核心在不明确的队列行已权威地移动到失败之后调用它,包括重试预算耗尽。使用它来移除不再需要的提供商拥有的计划或载荷。清理是尽力而为且必须幂等;失败会被记录,而不会使终态队列行再次可重放。

延迟投递准入

当已解析账户无法安全接受核心管理的出站或延迟投递时,使用 message.durableFinal.admitDeferredDelivery(...)。核心在实时出站工作之前同步调用此钩子,包括跳过队列持久化的路径,并在重放恢复的意图之前再次调用。上下文包括 cfg、channel、to、accountId,以及值为 live 或 recovery 的 phase。

返回 { status: "allowed" } 以继续。当投递不得持久化、直接发送或重放时,返回 { status: "permanent_rejection", reason }。实时拒绝会在队列创建、消息钩子或平台工作之前失败。恢复拒绝会将排队记录标记为失败,并跳过对账和重放。省略该钩子表示允许。

该钩子是同步准入决策,而不是发送路径。只读取已加载的配置或运行时状态;不要执行网络、文件系统或其他异步 I/O。契约测试应通过 openclaw/plugin-sdk/channel-outbound 中的 ChannelMessageDurableFinalAdapter 验证两个阶段和两种结果变体。

兼容性分发

通过 channel-inbound 中的 dispatchChannelInboundReply(...) 组装入站回复分发。将平台投递保留在投递适配器中;使用 channel-outbound 处理消息适配器、持久发送、回执、实时预览和回复管道选项。

从 channel-message 迁移

openclaw/plugin-sdk/channel-message 是一个已弃用的兼容性入口点。它保留其已发布的出站导出和三个分发别名。新的出站辅助函数仅从 openclaw/plugin-sdk/channel-outbound 导出。将这些别名迁移到 openclaw/plugin-sdk/channel-inbound:

已弃用别名 替换
hasFinalChannelTurnDispatch hasFinalInboundReplyDispatch
hasVisibleChannelTurnDispatch hasVisibleInboundReplyDispatch
resolveChannelTurnDispatchCounts resolveInboundReplyDispatchCounts

遵循 Migration 中带日期的移除资格窗口。此子路径不绑定到下一个 Plugin SDK 主要版本,且资格本身不会移除某个导出。外部导入不会发出运行时警告;请更新插件导入,而不是等待警告。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 cl/openclaw