Appearance
OpenClaw 的渠道对话轮次内核(Channel Turn Kernel)是一个标准化的入站消息处理管线:将平台原始事件转化为 agent 对话轮次,统一管理准入决策、事实组装、会话记录和交付回调。渠道插件只需提供适配器(adapter)回调,内核接管 orchestration。通过 runtime.channel.turn.run()、runAssembled()、runPrepared() 三个入口,适配器可选择全托管、半托管或自定义派发。该内核适用于入站消息热路径,非消息事件(斜杠命令、模态、按钮等)应保持插件本地处理。
OpenClaw 渠道对话轮次内核怎么配置与排查
渠道对话轮次内核(channel turn kernel)是一个共享的入站状态机,负责将标准化平台事件转化为 agent 对话轮次。渠道插件提供平台事实和交付回调,内核负责编排:ingest、classify、preflight、resolve、authorize、assemble、record、dispatch、finalize。
你的插件处于入站消息热路径时使用此内核。非消息事件(斜杠命令、模态、按钮交互、生命周期事件、反应、语音状态)应保持插件本地处理。该内核只处理可能成为 agent 文本对话轮次的事件。
INFO
内核通过注入的插件运行时以 runtime.channel.turn.* 访问。插件运行时类型从 openclaw/plugin-sdk/core 导出,第三方原生插件可以与捆绑渠道插件相同的方式使用这些入口点。
为什么需要共享内核
渠道插件重复相同的入站流程:标准化、路由、门控、构建上下文、记录会话元数据、派发 agent 对话轮次、完成交付状态。没有共享内核时,对提及门控、仅工具可见回复、会话元数据、待处理历史或交付完成的更改必须逐个渠道应用。
内核有意保持四个概念分离:
ConversationFacts:消息来自何处RouteFacts:哪个 agent 和会话处理该消息ReplyPlanFacts:可见回复应发往何处MessageFacts:agent 应看到的正文和补充上下文
Slack 私信、Telegram 话题、Matrix 线程、飞书话题会话在实践中都区分这些概念。将它们视为一个标识符会导致长期漂移。
阶段生命周期
无论渠道如何,内核运行相同的固定管线:
ingest– 适配器将原始平台事件转换为NormalizedTurnInputclassify– 适配器声明该事件能否开始一个 agent 对话轮次preflight– 适配器执行去重、自回显、水化、防抖、解密、部分事实预填resolve– 适配器返回完整组装的轮次(路由、回复计划、消息、交付)authorize– 对组装的事实应用 DM、群组、提及和命令策略assemble– 通过buildContext从事实构建FinalizedMsgContextrecord– 持久化入站会话元数据和最后路由dispatch– 通过缓冲块派发器执行 agent 对话轮次finalize– 即使派发出错,onFinalize回调也会运行
每个阶段在提供 log 回调时会发出结构化日志事件。详见可观测性。
准入类型
内核在轮次被门控时不抛出异常,而是返回 ChannelTurnAdmission:
| 类型 | 何时发生 |
|---|---|
dispatch | 轮次被准入。agent 轮次执行,可见回复路径被触发。 |
observeOnly | 轮次完整执行,但交付适配器不发送任何可见内容。用于广播观察者 agent 等被动多 agent 流程。 |
handled | 平台事件被本地消费(生命周期、反应、按钮、模态)。内核跳过派发。 |
drop | 跳过路径。可选 recordHistory: true:将消息保留在待处理群组历史中,以便将来提及时有上下文。 |
准入可以来自 classify(事件类型表示无法开始轮次)、preflight(去重、自回显、缺少提及但记录历史)或 resolveTurn 本身。
入口点
运行时暴露三个首选入口点,适配器可以根据渠道选择匹配的级别。
typescript
runtime.channel.turn.run(...) // 适配器驱动的完整管线
runtime.channel.turn.runAssembled(...) // 已构建上下文 + 交付适配器
runtime.channel.turn.runPrepared(...) // 渠道掌握派发;内核执行 record + finalize
runtime.channel.turn.buildContext(...) // 纯事实到 FinalizedMsgContext 的映射两个旧的运行时帮助器保留用于插件 SDK 兼容性:
typescript
runtime.channel.turn.runResolved(...) // 已弃用的兼容性别名;优先使用 run
runtime.channel.turn.dispatchAssembled(...) // 已弃用的兼容性别名;优先使用 runAssembledrun
当你的渠道可以将入站流程表示为 ChannelTurnAdapter<TRaw> 时使用。适配器具有 ingest 回调、可选的 classify、可选的 preflight、必选的 resolveTurn 和可选的 onFinalize。
typescript
await runtime.channel.turn.run({
channel: "tlon",
accountId,
raw: platformEvent,
adapter: {
ingest(raw) {
return {
id: raw.messageId,
timestamp: raw.timestamp,
rawText: raw.body,
textForAgent: raw.body,
};
},
classify(input) {
return { kind: "message", canStartAgentTurn: input.rawText.length > 0 };
},
async preflight(input, eventClass) {
if (await isDuplicate(input.id)) {
return { admission: { kind: "drop", reason: "dedupe" } };
}
return {};
},
resolveTurn(input) {
return buildAssembledTurn(input);
},
onFinalize(result) {
clearPendingGroupHistory(result);
},
},
});run 适用于渠道适配器逻辑较小且希望通过钩子拥有生命周期的情况。
runAssembled
当渠道已解析路由、构建了 FinalizedMsgContext,并且只需要共享的 recd、回复管线、派发和最终化排序时使用。这是简单捆绑入站路径的首选形状,否则会重复 createChannelMessageReplyPipeline(...) 和 runPrepared(...) 样板代码。
typescript
await runtime.channel.turn.runAssembled({
cfg,
channel: "irc",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await sendPlatformReply(payload);
},
onError: (err, info) => {
runtime.error?.(`reply ${info.kind} failed: ${String(err)}`);
},
},
});当渠道仅有的派发行为是最终负载交付加上可选的输入状态、回复选项、持久交付或错误日志记录时,选择 runAssembled 而非 runPrepared。
runPrepared
当渠道有一个复杂的本地派发器(包含预览、重试、编辑或线程引导)且必须保持渠道所有时使用。内核仍会在派发前记录入站会话,并返回统一的 DispatchedChannelTurnResult。
typescript
const { dispatchResult } = await runtime.channel.turn.runPrepared({
channel: "matrix",
accountId,
routeSessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
onRecordError,
updateLastRoute,
},
onPreDispatchFailure: async (err) => {
await stopStatusReactions();
},
runDispatch: async () => {
return await runMatrixOwnedDispatcher();
},
});功能丰富的渠道(Matrix、Mattermost、Microsoft Teams、飞书、QQ Bot)使用 runPrepared,因为它们派发器编排的平台特定行为内核不应了解。
buildContext
一个纯函数,将事实包映射到 FinalizedMsgContext。当你的渠道手动编排部分管线但希望保持一致性上下文形状时使用。
typescript
const ctxPayload = runtime.channel.turn.buildContext({
channel: "googlechat",
accountId,
messageId,
timestamp,
from,
sender,
conversation,
route,
reply,
message,
access,
media,
supplemental,
});buildContext 在 resolveTurn 回调中组装轮次以用于 run 时也很有用。
INFO
已弃用的 SDK 帮助器(如 dispatchInboundReplyWithBase)仍然通过组装轮次帮助器桥接。新插件代码应使用 run 或 runPrepared。
事实类型
内核从适配器消费的事实是平台无关的。在将平台对象传递给内核之前,将其转换为这些形状。
NormalizedTurnInput
| 字段 | 用途 |
|---|---|
id | 用于去重和日志的稳定消息 ID |
timestamp | 可选的纪元毫秒时间戳 |
rawText | 从平台接收到的正文 |
textForAgent | 可选的清理后正文用于 agent(提及剥离、空格裁剪) |
textForCommands | 可选的用于/命令解析的正文 |
raw | 可选透传引用,供需要原始对象的适配器回调使用 |
ChannelEventClass
| 字段 | 用途 |
|---|---|
kind | message、command、interaction、reaction、lifecycle、unknown |
canStartAgentTurn | 如果为 false,内核返回 { kind: "handled" } |
requiresImmediateAck | 提示需要立即确认的适配器(在派发前) |
SenderFacts
| 字段 | 用途 |
|---|---|
id | 稳定的平台发送者 ID |
name | 显示名 |
username | 如果与name不同则为用户名/句柄 |
tag | Discord 风格的鉴别符或平台标签 |
roles | 角色 ID,用于成员角色允许列表匹配 |
isBot | 当发送者是已知机器人时为 true(内核用于丢弃) |
isSelf | 当发送者是配置的 agent 本身时为 true |
displayLabel | 预渲染的标签用于信封信封文本 |
ConversationFacts
| 字段 | 用途 |
|---|---|
kind | direct、group 或 channel |
id | 用于路由的会话 ID |
label | 用于信封的人类可读标签 |
spaceId | 可选的外部空间标识符(Slack workspace、Matrix homeserver) |
parentId | 当此消息在线程中时的外部会话 ID |
threadId | 当此消息在线程中时的线程 ID |
nativeChannelId | 平台原生渠道 ID(当与路由 ID 不同时) |
routePeer | 用于 resolveAgentRoute 查找的对等点 |
RouteFacts
| 字段 | 用途 |
|---|---|
agentId | 应处理该轮次的 agent |
accountId | 可选的覆盖(多账号渠道) |
routeSessionKey | 用于路由的会话键 |
dispatchSessionKey | 派发时使用的会话键(与路由键不同时) |
persistedSessionKey | 写入持久化会话元数据的会话键 |
parentSessionKey | 分支/线程化会话的父级 |
modelParentSessionKey | 分支会话的模型侧父级 |
mainSessionKey | 直接对话的主 DM 所有者引脚 |
createIfMissing | 允许记录步骤创建缺失的会话行 |
ReplyPlanFacts
| 字段 | 用途 |
|---|---|
to | 写入上下文 To 的逻辑回复目标 |
originatingTo | 原始上下文目标(OriginatingTo) |
nativeChannelId | 用于交付的平台原生渠道 ID |
replyTarget | 最终可见回复目的地(如果与 to 不同) |
deliveryTarget | 较低级别的交付覆盖 |
replyToId | 引用/锚定的消息 ID |
replyToIdFull | 平台同时拥有完整引用 ID 时的完整形式 |
messageThreadId | 交付时的线程 ID |
threadParentId | 线程的父消息 ID |
sourceReplyDeliveryMode | thread、reply、channel、direct 或 none |
AccessFacts
AccessFacts 携带授权阶段需要的布尔值。身份匹配保留在渠道中:内核仅消费结果。
| 字段 | 用途 |
|---|---|
dm | DM 允许/配对/拒绝决策及 allowFrom 列表 |
group | 群组策略、路由允许、发送者允许、允许列表、提及要求 |
commands | 跨配置的授权器的命令授权 |
mentions | 提及检测是否可能以及 agent 是否被提及 |
MessageFacts
| 字段 | 用途 |
|---|---|
body | 最终信封正文(格式化后的) |
rawBody | 原始入站正文 |
bodyForAgent | agent 看到的正文 |
commandBody | 用于命令解析的正文 |
envelopeFrom | 预渲染的发送者标签用于信封 |
senderLabel | 可选的覆盖渲染的发送者 |
preview | 简短脱敏预览用于日志 |
inboundHistory | 渠道保留缓冲区时的最近入站历史条目 |
SupplementalContextFacts
补充上下文涵盖引用、转发和线程引导上下文。内核应用配置的 contextVisibility 策略。渠道适配器仅提供事实和 senderAllowed 标志,使得跨渠道策略保持一致。
InboundMediaFacts
媒体是事实形状的。平台下载、认证、SSRF 策略、CDN 规则和解密保持在渠道本地。内核将事实映射到 MediaPath、MediaUrl、MediaType、MediaPaths、MediaUrls、MediaTypes 和 MediaTranscribedIndexes。
当渠道有已解析的媒体列表且只需要附加通用事实时,使用来自 openclaw/plugin-sdk/channel-inbound 的 toInboundMediaFacts(...):
typescript
media: toInboundMediaFacts(resolvedMedia, {
kind: "image",
messageId: input.id,
});如果媒体混合了本地文件和仅 URL 条目,保持列表为媒体事实。内核在写入旧的上下文字段时保留数组索引,以便下游媒体理解、转录标记和提示注释继续引用同一附件。
对于已跳过的群组消息(稍后提及时应可用),通过轮次的 preflight.media 字段传递媒体事实。内核在记录前将这些事实转换为有限的历史媒体条目:
typescript
preflight(input) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
media: () => toInboundMediaFacts(resolveLocalImages(input), {
kind: "image",
messageId: input.id,
}),
history: {
key: historyKey,
limit: historyLimit,
mediaLimit: 4,
shouldRecord: () => stillCurrent(input),
},
};
}历史媒体有意保持保守:今天仅限图片、仅限本地可读路径、受配置的媒体限制约束,并且仍绑定到渠道历史键。已验证的提供商 URL 应由插件在成为模型可见媒体之前下载。
历史窗口
消息轮次代码应使用 createChannelHistoryWindow(...) 而不是直接调用低层 reply-history map 帮助器。旧的 map 帮助器作为已弃用的兼容性导出仍然可导入,但新插件运行时代码不应调用它们。窗口门面将文本上下文、结构化 InboundHistory、历史媒体规范化和清除保持在一个核心拥有的 API 后面,同时仍然让渠道选择历史行的渲染方式。
typescript
const history = createChannelHistoryWindow({ historyMap: groupHistories });
await history.recordWithMedia({
historyKey,
limit: historyLimit,
entry,
media: () =>
toInboundMediaFacts(resolvedImages, {
kind: "image",
messageId: entry.messageId,
}),
});
const combinedBody = history.buildPendingContext({
historyKey,
limit: historyLimit,
currentMessage,
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
});较旧的导出 buildPendingHistoryContextFromMap、buildInboundHistoryFromMap、recordPendingHistoryEntry* 和 clearHistoryEntries* 作为已弃用的兼容性保留,用于尚未迁移的插件。新的渠道工作应使用窗口或轮次内核 record/finalize 选项。
常见消息模式
仅文本群组且需要提及:
typescript
preflight(input) {
const decision = resolveInboundMentionDecision({ facts, policy });
if (decision.shouldSkip) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
history: { key: historyKey, limit: historyLimit },
};
}
return { access: { mentions: decision } };
}仅图片消息后跟提及:
typescript
preflight(input) {
if (!wasMentioned && resolvedImages.length > 0) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
media: () => toInboundMediaFacts(resolvedImages, {
kind: "image",
messageId: input.id,
}),
history: { key: historyKey, limit: historyLimit, mediaLimit: 4 },
};
}
return {};
}显式回复图片:
typescript
resolveTurn(input, _eventClass, preflight) {
return {
...assembled,
media: toInboundMediaFacts([...currentMedia, ...referencedReplyMedia]),
supplemental: {
quote: preflight.supplemental?.quote,
},
};
}带历史的私信:
typescript
resolveTurn(input) {
return {
...assembled,
history: undefined,
message: {
rawBody: input.rawText,
bodyForAgent: input.textForAgent,
},
};
}适配器契约
对于完整 run,适配器形状为:
typescript
type ChannelTurnAdapter<TRaw> = {
ingest(raw: TRaw): Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
classify?(input: NormalizedTurnInput): Promise<ChannelEventClass> | ChannelEventClass;
preflight?(
input: NormalizedTurnInput,
eventClass: ChannelEventClass,
): Promise<PreflightFacts | ChannelTurnAdmission | null | undefined>;
resolveTurn(
input: NormalizedTurnInput,
eventClass: ChannelEventClass,
preflight: PreflightFacts,
): Promise<ChannelTurnResolved> | ChannelTurnResolved;
onFinalize?(result: ChannelTurnResult): Promise<void> | void;
};resolveTurn 返回 ChannelTurnResolved,它是一个带有可选准入类型的 AssembledChannelTurn。返回 { admission: { kind: "observeOnly" } } 会执行轮次但不产生可见输出。适配器仍然拥有交付回调;只是对于该轮次变为 no-op。
onFinalize 在每次结果时运行,包括派发错误。用于清除待处理群组历史、移除确认反应、停止状态指示器和刷新本地状态。
交付适配器
内核不直接调用平台。渠道向内核提供一个 ChannelEventDeliveryAdapter:
typescript
type ChannelEventDeliveryAdapter = {
deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise<ChannelDeliveryResult | void>;
onError?(err: unknown, info: { kind: string }): void;
durable?: false | DurableInboundReplyDeliveryOptions;
};
type ChannelDeliveryResult = {
messageIds?: string[];
receipt?: MessageReceipt;
threadId?: string;
replyToId?: string;
visibleReplySent?: boolean;
};deliver 对每个缓冲回复块调用一次。在消息生命周期迁移期间,已组装的渠道事件交付默认由渠道所有:省略的 durable 字段意味着内核必须直接调用 deliver,并且不得通过通用出站交付路由。仅在审计渠道证明通用发送路径保持旧的交付行为(包括回复/线程目标、媒体处理、已发送消息/自回显缓存、状态清理和返回的消息 ID)后才设置 durable。durable: false 作为“使用渠道拥有的回调”的兼容拼写保留,但未迁移的渠道不应需要添加它。当渠道有平台消息 ID 时返回,以便派发器保留线程锚点并稍后编辑块;较新的交付路径也应返回 receipt,以便恢复、预览最终化和重复抑制可以离开 messageIds。对于仅观察轮次,返回 { visibleReplySent: false } 或使用 createNoopChannelEventDeliveryAdapter()。
使用 runPrepared 和完全渠道拥有的派发器的渠道没有 ChannelEventDeliveryAdapter。这些派发器默认不是持久化的。它们应保持直接交付路径,直到明确选择新的发送上下文,包括完整目标、重放安全适配器、收据契约和渠道副作用钩子。
公共兼容性帮助器(如 recordInboundSessionAndDispatchReply、dispatchInboundReplyWithBase 和直接 DM 帮助器)在迁移期间必须保持行为不变。它们不应在调用者拥有的 deliver 或 reply 回调之前调用通用持久交付。
Record 选项
记录阶段包装 recordInboundSession。大多数渠道可以使用默认值。通过 record 覆盖:
typescript
record: {
groupResolution,
createIfMissing: true,
updateLastRoute,
onRecordError: (err) => log.warn("record failed", err),
trackSessionMetaTask: (task) => pendingTasks.push(task),
}派发器等待记录阶段。如果记录抛出错误,内核运行 onPreDispatchFailure(如果提供给 runPrepared)并重新抛出。
可观测性
每个阶段在提供 log 回调时发出结构化事件:
typescript
await runtime.channel.turn.run({
channel: "twitch",
accountId,
raw,
adapter,
log: (event) => {
runtime.log?.debug?.(`turn.${event.stage}:${event.event}`, {
channel: event.channel,
accountId: event.accountId,
messageId: event.messageId,
sessionKey: event.sessionKey,
admission: event.admission,
reason: event.reason,
});
},
});记录的阶段:ingest、classify、preflight、resolve、authorize、assemble、record、dispatch、finalize。避免记录原始正文;使用 MessageFacts.preview 获取短脱敏预览。
保留渠道本地的内容
内核拥有编排。渠道仍然拥有:
- 平台传输(网关、REST、WebSocket、轮询、Webhook)
- 身份解析和显示名称匹配
- 原生命令、斜杠命令、自动补全、模态、按钮、语音状态
- 卡片、模态和自适应卡片渲染
- 媒体认证、CDN 规则、加密媒体、转录
- 编辑、反应、删除和出现 API
- 回填和平台侧历史获取
- 要求平台特定验证的配对流程
如果两个渠道开始需要相同的帮助器来处理这些之一,提取共享 SDK 帮助器而不是将其推入内核。
稳定性
runtime.channel.turn.* 是公共插件运行时表面的一部分。事实类型(SenderFacts、ConversationFacts、RouteFacts、ReplyPlanFacts、AccessFacts、MessageFacts、SupplementalContextFacts、InboundMediaFacts)和准入形状(ChannelTurnAdmission、ChannelEventClass)可通过 openclaw/plugin-sdk/core 的 PluginRuntime 访问。
向后兼容规则适用:新事实字段是附加的,准入类型不会被重命名,入口点名称保持稳定。需要非附加更改的新渠道需求必须通过插件 SDK 迁移过程。
相关
常见问题
为什么我的渠道插件入站消息没有被内核处理?
检查你是否使用了 runtime.channel.turn.* 中的入口点之一。非消息事件(如按钮交互、斜杠命令、模态提交)不应通过内核,它们应由插件本地处理。另外确认适配器中的 ingest 返回了有效的 NormalizedTurnInput 且 classify 中 canStartAgentTurn 为 true。
drop 准入类型与 handled 有什么区别?
drop 表示轮次被跳过(例如由于去重、缺少提及),但可选地可以记录历史以便将来提及时有上下文。handled 表示平台事件被插件本地完全消费(如处理了一个按钮点击),内核不会运行派发。在 classify 或 preflight 中返回适当的准入类型以控制行为。
runPrepared 和 runAssembled 我应该怎么选?
当渠道拥有复杂的派发逻辑(预览、重试、编辑、线程引导)且应保留派发所有权时使用 runPrepared。当渠道只需要将最终负载传递给平台交付回调且没有其他派发逻辑时使用 runAssembled。使用 runPrepared 时,提供 runDispatch 回调实现平台特定的派发流程。