feat: add channel message lifecycle sdk

This commit is contained in:
Peter Steinberger
2026-05-06 01:40:22 +01:00
parent 411211c21b
commit 8bfabd6bb1
49 changed files with 4996 additions and 208 deletions

View File

@@ -798,6 +798,14 @@
"types": "./dist/plugin-sdk/channel-lifecycle.d.ts",
"default": "./dist/plugin-sdk/channel-lifecycle.js"
},
"./plugin-sdk/channel-message": {
"types": "./dist/plugin-sdk/channel-message.d.ts",
"default": "./dist/plugin-sdk/channel-message.js"
},
"./plugin-sdk/channel-message-runtime": {
"types": "./dist/plugin-sdk/channel-message-runtime.d.ts",
"default": "./dist/plugin-sdk/channel-message-runtime.js"
},
"./plugin-sdk/channel-pairing": {
"types": "./dist/plugin-sdk/channel-pairing.d.ts",
"default": "./dist/plugin-sdk/channel-pairing.js"

View File

@@ -176,6 +176,8 @@
"channel-location",
"channel-mention-gating",
"channel-lifecycle",
"channel-message",
"channel-message-runtime",
"channel-pairing",
"channel-pairing-paths",
"channel-policy",

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { buildAttemptSystemPrompt } from "./attempt-system-prompt.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let buildAttemptSystemPrompt: typeof import("./attempt-system-prompt.js").buildAttemptSystemPrompt;
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../system-prompt.js");
({ buildAttemptSystemPrompt } = await import("./attempt-system-prompt.js"));
});
const baseProviderTransform = {
provider: "openai",

View File

@@ -1,16 +1,25 @@
export type DraftPreviewFinalizerDraft<TId> = {
flush: () => Promise<void>;
id: () => TId | undefined;
seal?: () => Promise<void>;
discardPending?: () => Promise<void>;
clear: () => Promise<void>;
};
import {
deliverFinalizableLivePreview,
type LivePreviewFinalizerDraft,
type LivePreviewFinalizerResultKind,
} from "./message/live.js";
export type DraftPreviewFinalizerResult =
| "normal-delivered"
| "normal-skipped"
| "preview-finalized";
/**
* @deprecated Use `LivePreviewFinalizerDraft` from `openclaw/plugin-sdk/channel-message`.
*/
export type DraftPreviewFinalizerDraft<TId> = LivePreviewFinalizerDraft<TId>;
/**
* @deprecated Use `LivePreviewFinalizerResult` from `openclaw/plugin-sdk/channel-message`.
*/
export type DraftPreviewFinalizerResult = Exclude<
LivePreviewFinalizerResultKind,
"preview-retained"
>;
/**
* @deprecated Use `deliverFinalizableLivePreview` from `openclaw/plugin-sdk/channel-message`.
*/
export async function deliverFinalizableDraftPreview<TPayload, TId, TEdit>(params: {
kind: "tool" | "block" | "final";
payload: TPayload;
@@ -22,49 +31,21 @@ export async function deliverFinalizableDraftPreview<TPayload, TId, TEdit>(param
onNormalDelivered?: () => Promise<void> | void;
logPreviewEditFailure?: (error: unknown) => void;
}): Promise<DraftPreviewFinalizerResult> {
if (params.kind !== "final" || !params.draft) {
const delivered = await params.deliverNormally(params.payload);
if (delivered === false) {
return "normal-skipped";
}
await params.onNormalDelivered?.();
return "normal-delivered";
}
const result = await deliverFinalizableLivePreview({
kind: params.kind,
payload: params.payload,
...(params.draft ? { draft: params.draft } : {}),
buildFinalEdit: params.buildFinalEdit,
editFinal: params.editFinal,
deliverNormally: params.deliverNormally,
onPreviewFinalized: async (id) => {
await params.onPreviewFinalized?.(id);
},
...(params.onNormalDelivered ? { onNormalDelivered: params.onNormalDelivered } : {}),
...(params.logPreviewEditFailure
? { logPreviewEditFailure: params.logPreviewEditFailure }
: {}),
});
const edit = params.buildFinalEdit(params.payload);
if (edit !== undefined) {
await params.draft.flush();
const previewId = params.draft.id();
if (previewId !== undefined) {
await params.draft.seal?.();
try {
await params.editFinal(previewId, edit);
await params.onPreviewFinalized?.(previewId);
return "preview-finalized";
} catch (err) {
params.logPreviewEditFailure?.(err);
}
}
}
if (params.draft.discardPending) {
await params.draft.discardPending();
} else {
await params.draft.clear();
}
let delivered = false;
try {
const result = await params.deliverNormally(params.payload);
delivered = result !== false;
if (delivered) {
await params.onNormalDelivered?.();
}
} finally {
if (delivered) {
await params.draft.clear();
}
}
return delivered ? "normal-delivered" : "normal-skipped";
return result.kind === "preview-retained" ? "normal-skipped" : result.kind;
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { deriveDurableFinalDeliveryRequirements } from "./capabilities.js";
describe("deriveDurableFinalDeliveryRequirements", () => {
it("derives the default durable final text and hook requirements", () => {
expect(deriveDurableFinalDeliveryRequirements({ payload: { text: "hello" } })).toEqual({
text: true,
messageSendingHooks: true,
});
});
it("derives payload-dependent delivery requirements", () => {
expect(
deriveDurableFinalDeliveryRequirements({
payload: {
text: "caption",
mediaUrls: ["https://example.com/a.png"],
replyToId: "reply-1",
},
threadId: 42,
silent: true,
payloadTransport: true,
batch: true,
reconcileUnknownSend: true,
afterSendSuccess: true,
afterCommit: true,
}),
).toEqual({
text: true,
media: true,
replyTo: true,
thread: true,
silent: true,
messageSendingHooks: true,
payload: true,
batch: true,
reconcileUnknownSend: true,
afterSendSuccess: true,
afterCommit: true,
});
});
it("applies channel-native extras without recording false requirements", () => {
expect(
deriveDurableFinalDeliveryRequirements({
payload: { text: "hello" },
extraCapabilities: {
nativeQuote: false,
thread: true,
},
}),
).toEqual({
text: true,
thread: true,
messageSendingHooks: true,
});
});
});

View File

@@ -0,0 +1,56 @@
import type {
DeriveDurableFinalDeliveryRequirementsParams,
DurableFinalDeliveryCapability,
DurableFinalDeliveryRequirementMap,
} from "./types.js";
function hasMediaPayload(
payload: DeriveDurableFinalDeliveryRequirementsParams["payload"],
): boolean {
if (payload.mediaUrl?.trim()) {
return true;
}
return (
Array.isArray(payload.mediaUrls) &&
payload.mediaUrls.some((url) => typeof url === "string" && url.trim().length > 0)
);
}
function setRequired(
requirements: DurableFinalDeliveryRequirementMap,
capability: DurableFinalDeliveryCapability,
required: boolean | undefined,
): void {
if (required === true) {
requirements[capability] = true;
}
}
export function deriveDurableFinalDeliveryRequirements(
params: DeriveDurableFinalDeliveryRequirementsParams,
): DurableFinalDeliveryRequirementMap {
const requirements: DurableFinalDeliveryRequirementMap = {};
setRequired(requirements, "text", true);
setRequired(requirements, "media", hasMediaPayload(params.payload));
setRequired(
requirements,
"replyTo",
params.replyToId != null || params.payload.replyToId != null,
);
setRequired(requirements, "thread", params.threadId != null);
setRequired(requirements, "silent", params.silent);
setRequired(requirements, "messageSendingHooks", params.messageSendingHooks !== false);
setRequired(requirements, "payload", params.payloadTransport);
setRequired(requirements, "batch", params.batch);
setRequired(requirements, "reconcileUnknownSend", params.reconcileUnknownSend);
setRequired(requirements, "afterSendSuccess", params.afterSendSuccess);
setRequired(requirements, "afterCommit", params.afterCommit);
for (const [capability, required] of Object.entries(params.extraCapabilities ?? {}) as Array<
[DurableFinalDeliveryCapability, boolean | undefined]
>) {
setRequired(requirements, capability, required);
}
return requirements;
}

View File

@@ -0,0 +1,293 @@
import { describe, expect, it, vi } from "vitest";
import {
listDeclaredChannelMessageLiveCapabilities,
listDeclaredDurableFinalCapabilities,
listDeclaredLivePreviewFinalizerCapabilities,
listDeclaredReceiveAckPolicies,
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
verifyChannelMessageLiveCapabilityProofs,
verifyChannelMessageReceiveAckPolicyAdapterProofs,
verifyChannelMessageReceiveAckPolicyProofs,
verifyDurableFinalCapabilityProofs,
verifyLivePreviewFinalizerCapabilityProofs,
} from "./contracts.js";
describe("durable final capability contracts", () => {
it("lists declared durable-final capabilities in stable order", () => {
expect(
listDeclaredDurableFinalCapabilities({
batch: true,
afterCommit: true,
reconcileUnknownSend: true,
text: true,
silent: false,
thread: true,
}),
).toEqual(["text", "thread", "batch", "reconcileUnknownSend", "afterCommit"]);
});
it("runs proofs for every declared durable-final capability", async () => {
const text = vi.fn();
const silent = vi.fn(async () => {});
await expect(
verifyDurableFinalCapabilityProofs({
adapterName: "demo",
capabilities: {
text: true,
silent: true,
},
proofs: {
text,
silent,
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "silent", status: "verified" },
]),
);
expect(text).toHaveBeenCalledTimes(1);
expect(silent).toHaveBeenCalledTimes(1);
});
it("fails when a declared durable-final capability has no proof", async () => {
await expect(
verifyDurableFinalCapabilityProofs({
adapterName: "demo",
capabilities: {
text: true,
nativeQuote: true,
},
proofs: {
text: () => {},
},
}),
).rejects.toThrow(
'demo declares durable final capability "nativeQuote" without a contract proof',
);
});
it("runs proofs from channel message adapter declarations", async () => {
const text = vi.fn();
const media = vi.fn();
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "demo",
adapter: {
durableFinal: {
capabilities: {
text: true,
media: true,
},
},
},
proofs: {
text,
media,
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
]),
);
expect(text).toHaveBeenCalledTimes(1);
expect(media).toHaveBeenCalledTimes(1);
});
it("runs live preview finalizer proofs from channel message adapter declarations", async () => {
const finalEdit = vi.fn();
const normalFallback = vi.fn();
expect(
listDeclaredLivePreviewFinalizerCapabilities({
previewReceipt: false,
normalFallback: true,
finalEdit: true,
}),
).toEqual(["finalEdit", "normalFallback"]);
await expect(
verifyChannelMessageLiveFinalizerProofs({
adapterName: "demo",
adapter: {
live: {
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
},
},
},
},
proofs: {
finalEdit,
normalFallback,
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "finalEdit", status: "verified" },
{ capability: "normalFallback", status: "verified" },
]),
);
expect(finalEdit).toHaveBeenCalledTimes(1);
expect(normalFallback).toHaveBeenCalledTimes(1);
});
it("runs live capability proofs from channel message adapter declarations", async () => {
const draftPreview = vi.fn();
const previewFinalization = vi.fn();
expect(
listDeclaredChannelMessageLiveCapabilities({
nativeStreaming: false,
previewFinalization: true,
draftPreview: true,
}),
).toEqual(["draftPreview", "previewFinalization"]);
await expect(
verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "demo",
adapter: {
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
},
},
},
proofs: {
draftPreview,
previewFinalization,
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "draftPreview", status: "verified" },
{ capability: "previewFinalization", status: "verified" },
]),
);
expect(draftPreview).toHaveBeenCalledTimes(1);
expect(previewFinalization).toHaveBeenCalledTimes(1);
});
it("fails when a declared live preview finalizer capability has no proof", async () => {
await expect(
verifyLivePreviewFinalizerCapabilityProofs({
adapterName: "demo",
capabilities: {
finalEdit: true,
previewReceipt: true,
},
proofs: {
finalEdit: () => {},
},
}),
).rejects.toThrow(
'demo declares live preview finalizer capability "previewReceipt" without a contract proof',
);
});
it("fails when a declared live capability has no proof", async () => {
await expect(
verifyChannelMessageLiveCapabilityProofs({
adapterName: "demo",
capabilities: {
draftPreview: true,
progressUpdates: true,
},
proofs: {
draftPreview: () => {},
},
}),
).rejects.toThrow('demo declares live capability "progressUpdates" without a contract proof');
});
it("runs receive ack policy proofs from channel message adapter declarations", async () => {
const afterReceiveRecord = vi.fn();
const afterAgentDispatch = vi.fn();
expect(
listDeclaredReceiveAckPolicies({
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_agent_dispatch", "after_receive_record"],
}),
).toEqual(["after_receive_record", "after_agent_dispatch"]);
await expect(
verifyChannelMessageReceiveAckPolicyAdapterProofs({
adapterName: "demo",
adapter: {
receive: {
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
},
},
proofs: {
after_receive_record: afterReceiveRecord,
after_agent_dispatch: afterAgentDispatch,
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ policy: "after_receive_record", status: "verified" },
{ policy: "after_agent_dispatch", status: "verified" },
]),
);
expect(afterReceiveRecord).toHaveBeenCalledTimes(1);
expect(afterAgentDispatch).toHaveBeenCalledTimes(1);
});
it("falls back to the default receive ack policy when supported policies are omitted", () => {
expect(
listDeclaredReceiveAckPolicies({
defaultAckPolicy: "after_durable_send",
}),
).toEqual(["after_durable_send"]);
});
it("treats manual receive acknowledgement as an explicit plugin-owned policy", async () => {
const manual = vi.fn();
expect(
listDeclaredReceiveAckPolicies({
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
}),
).toEqual(["manual"]);
await expect(
verifyChannelMessageReceiveAckPolicyProofs({
adapterName: "demo",
receive: {
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
},
proofs: { manual },
}),
).resolves.toEqual(expect.arrayContaining([{ policy: "manual", status: "verified" }]));
expect(manual).toHaveBeenCalledTimes(1);
});
it("fails when a declared receive ack policy has no proof", async () => {
await expect(
verifyChannelMessageReceiveAckPolicyProofs({
adapterName: "demo",
receive: {
supportedAckPolicies: ["after_receive_record", "manual"],
},
proofs: {
after_receive_record: () => {},
},
}),
).rejects.toThrow('demo declares receive ack policy "manual" without a contract proof');
});
});

View File

@@ -0,0 +1,233 @@
import type {
ChannelMessageAdapterShape,
ChannelMessageLiveCapability,
ChannelMessageReceiveAckPolicy,
DurableFinalDeliveryCapability,
DurableFinalDeliveryRequirementMap,
LivePreviewFinalizerCapability,
LivePreviewFinalizerCapabilityMap,
} from "./types.js";
import {
channelMessageLiveCapabilities,
channelMessageReceiveAckPolicies,
durableFinalDeliveryCapabilities,
livePreviewFinalizerCapabilities,
} from "./types.js";
export type DurableFinalCapabilityProof = () => Promise<void> | void;
export type DurableFinalCapabilityProofMap = Partial<
Record<DurableFinalDeliveryCapability, DurableFinalCapabilityProof>
>;
export type DurableFinalCapabilityProofResult = {
capability: DurableFinalDeliveryCapability;
status: "verified" | "not_declared";
};
export type LivePreviewFinalizerCapabilityProof = () => Promise<void> | void;
export type ChannelMessageLiveCapabilityProof = () => Promise<void> | void;
export type ChannelMessageReceiveAckPolicyProof = () => Promise<void> | void;
export type LivePreviewFinalizerCapabilityProofMap = Partial<
Record<LivePreviewFinalizerCapability, LivePreviewFinalizerCapabilityProof>
>;
export type ChannelMessageLiveCapabilityProofMap = Partial<
Record<ChannelMessageLiveCapability, ChannelMessageLiveCapabilityProof>
>;
export type ChannelMessageReceiveAckPolicyProofMap = Partial<
Record<ChannelMessageReceiveAckPolicy, ChannelMessageReceiveAckPolicyProof>
>;
export type LivePreviewFinalizerCapabilityProofResult = {
capability: LivePreviewFinalizerCapability;
status: "verified" | "not_declared";
};
export type ChannelMessageLiveCapabilityProofResult = {
capability: ChannelMessageLiveCapability;
status: "verified" | "not_declared";
};
export type ChannelMessageReceiveAckPolicyProofResult = {
policy: ChannelMessageReceiveAckPolicy;
status: "verified" | "not_declared";
};
export function listDeclaredDurableFinalCapabilities(
capabilities: DurableFinalDeliveryRequirementMap | undefined,
): DurableFinalDeliveryCapability[] {
return durableFinalDeliveryCapabilities.filter(
(capability) => capabilities?.[capability] === true,
);
}
export function listDeclaredLivePreviewFinalizerCapabilities(
capabilities: LivePreviewFinalizerCapabilityMap | undefined,
): LivePreviewFinalizerCapability[] {
return livePreviewFinalizerCapabilities.filter(
(capability) => capabilities?.[capability] === true,
);
}
export function listDeclaredChannelMessageLiveCapabilities(
capabilities: Partial<Record<ChannelMessageLiveCapability, boolean>> | undefined,
): ChannelMessageLiveCapability[] {
return channelMessageLiveCapabilities.filter((capability) => capabilities?.[capability] === true);
}
export function listDeclaredReceiveAckPolicies(
receive: ChannelMessageAdapterShape["receive"] | undefined,
): ChannelMessageReceiveAckPolicy[] {
const declared = receive?.supportedAckPolicies?.length
? receive.supportedAckPolicies
: receive?.defaultAckPolicy
? [receive.defaultAckPolicy]
: [];
return channelMessageReceiveAckPolicies.filter((policy) => declared.includes(policy));
}
export async function verifyDurableFinalCapabilityProofs(params: {
adapterName: string;
capabilities?: DurableFinalDeliveryRequirementMap;
proofs: DurableFinalCapabilityProofMap;
}): Promise<DurableFinalCapabilityProofResult[]> {
const results: DurableFinalCapabilityProofResult[] = [];
for (const capability of durableFinalDeliveryCapabilities) {
if (params.capabilities?.[capability] !== true) {
results.push({ capability, status: "not_declared" });
continue;
}
const proof = params.proofs[capability];
if (!proof) {
throw new Error(
`${params.adapterName} declares durable final capability "${capability}" without a contract proof`,
);
}
await proof();
results.push({ capability, status: "verified" });
}
return results;
}
export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
adapterName: string;
capabilities?: LivePreviewFinalizerCapabilityMap;
proofs: LivePreviewFinalizerCapabilityProofMap;
}): Promise<LivePreviewFinalizerCapabilityProofResult[]> {
const results: LivePreviewFinalizerCapabilityProofResult[] = [];
for (const capability of livePreviewFinalizerCapabilities) {
if (params.capabilities?.[capability] !== true) {
results.push({ capability, status: "not_declared" });
continue;
}
const proof = params.proofs[capability];
if (!proof) {
throw new Error(
`${params.adapterName} declares live preview finalizer capability "${capability}" without a contract proof`,
);
}
await proof();
results.push({ capability, status: "verified" });
}
return results;
}
export async function verifyChannelMessageLiveCapabilityProofs(params: {
adapterName: string;
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
proofs: ChannelMessageLiveCapabilityProofMap;
}): Promise<ChannelMessageLiveCapabilityProofResult[]> {
const results: ChannelMessageLiveCapabilityProofResult[] = [];
for (const capability of channelMessageLiveCapabilities) {
if (params.capabilities?.[capability] !== true) {
results.push({ capability, status: "not_declared" });
continue;
}
const proof = params.proofs[capability];
if (!proof) {
throw new Error(
`${params.adapterName} declares live capability "${capability}" without a contract proof`,
);
}
await proof();
results.push({ capability, status: "verified" });
}
return results;
}
export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
adapterName: string;
receive?: ChannelMessageAdapterShape["receive"];
proofs: ChannelMessageReceiveAckPolicyProofMap;
}): Promise<ChannelMessageReceiveAckPolicyProofResult[]> {
const declared = new Set(listDeclaredReceiveAckPolicies(params.receive));
const results: ChannelMessageReceiveAckPolicyProofResult[] = [];
for (const policy of channelMessageReceiveAckPolicies) {
if (!declared.has(policy)) {
results.push({ policy, status: "not_declared" });
continue;
}
const proof = params.proofs[policy];
if (!proof) {
throw new Error(
`${params.adapterName} declares receive ack policy "${policy}" without a contract proof`,
);
}
await proof();
results.push({ policy, status: "verified" });
}
return results;
}
export async function verifyChannelMessageAdapterCapabilityProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "durableFinal">;
proofs: DurableFinalCapabilityProofMap;
}): Promise<DurableFinalCapabilityProofResult[]> {
return await verifyDurableFinalCapabilityProofs({
adapterName: params.adapterName,
capabilities: params.adapter.durableFinal?.capabilities,
proofs: params.proofs,
});
}
export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "receive">;
proofs: ChannelMessageReceiveAckPolicyProofMap;
}): Promise<ChannelMessageReceiveAckPolicyProofResult[]> {
return await verifyChannelMessageReceiveAckPolicyProofs({
adapterName: params.adapterName,
receive: params.adapter.receive,
proofs: params.proofs,
});
}
export async function verifyChannelMessageLiveFinalizerProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;
proofs: LivePreviewFinalizerCapabilityProofMap;
}): Promise<LivePreviewFinalizerCapabilityProofResult[]> {
return await verifyLivePreviewFinalizerCapabilityProofs({
adapterName: params.adapterName,
capabilities: params.adapter.live?.finalizer?.capabilities,
proofs: params.proofs,
});
}
export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;
proofs: ChannelMessageLiveCapabilityProofMap;
}): Promise<ChannelMessageLiveCapabilityProofResult[]> {
return await verifyChannelMessageLiveCapabilityProofs({
adapterName: params.adapterName,
capabilities: params.adapter.live?.capabilities,
proofs: params.proofs,
});
}

View File

@@ -0,0 +1,125 @@
export { deriveDurableFinalDeliveryRequirements } from "./capabilities.js";
export { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js";
export {
listDeclaredChannelMessageLiveCapabilities,
listDeclaredDurableFinalCapabilities,
listDeclaredLivePreviewFinalizerCapabilities,
listDeclaredReceiveAckPolicies,
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
verifyChannelMessageLiveCapabilityProofs,
verifyChannelMessageReceiveAckPolicyAdapterProofs,
verifyChannelMessageReceiveAckPolicyProofs,
verifyDurableFinalCapabilityProofs,
verifyLivePreviewFinalizerCapabilityProofs,
} from "./contracts.js";
export {
createLiveMessageState,
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deliverFinalizableLivePreview,
deliverWithFinalizableLivePreviewAdapter,
markLiveMessageCancelled,
markLiveMessageFinalized,
markLiveMessagePreviewUpdated,
} from "./live.js";
export {
createMessageReceiptFromOutboundResults,
listMessageReceiptPlatformIds,
resolveMessageReceiptPrimaryId,
} from "./receipt.js";
export { createMessageReceiveContext, shouldAckMessageAfterStage } from "./receive.js";
export {
createChannelReplyPipeline,
createReplyPrefixContext,
createReplyPrefixOptions,
createTypingCallbacks,
resolveChannelSourceReplyDeliveryMode,
} from "./reply-pipeline.js";
export { classifyDurableSendRecoveryState, createDurableMessageStateRecord } from "./state.js";
export type {
ChannelMessageOutboundBridgeAdapter,
ChannelMessageOutboundBridgeResult,
CreateChannelMessageAdapterFromOutboundParams,
} from "./outbound-bridge.js";
export type {
ChannelMessageLiveCapabilityProof,
ChannelMessageLiveCapabilityProofMap,
ChannelMessageLiveCapabilityProofResult,
ChannelMessageReceiveAckPolicyProof,
ChannelMessageReceiveAckPolicyProofMap,
ChannelMessageReceiveAckPolicyProofResult,
DurableFinalCapabilityProof,
DurableFinalCapabilityProofMap,
DurableFinalCapabilityProofResult,
LivePreviewFinalizerCapabilityProof,
LivePreviewFinalizerCapabilityProofMap,
LivePreviewFinalizerCapabilityProofResult,
} from "./contracts.js";
export type {
ChannelReplyPipeline,
CreateChannelReplyPipelineParams,
CreateTypingCallbacksParams,
ReplyPrefixContext,
ReplyPrefixContextBundle,
ReplyPrefixOptions,
SourceReplyDeliveryMode,
TypingCallbacks,
} from "./reply-pipeline.js";
export type {
MessageAckPolicy,
MessageAckStage,
MessageAckState,
MessageReceiveContext,
} from "./receive.js";
export type {
LivePreviewFinalizerDraft,
FinalizableLivePreviewAdapter,
LivePreviewFinalizerResult,
LivePreviewFinalizerResultKind,
} from "./live.js";
export type { DurableMessageSendState, DurableMessageStateRecord } from "./state.js";
export type {
ChannelMessageAdapter,
ChannelMessageAdapterShape,
ChannelMessageDurableFinalAdapter,
ChannelMessageLiveFinalizerAdapterShape,
ChannelMessageLiveAdapterShape,
ChannelMessageLiveCapability,
ChannelMessageReceiveAckPolicy,
ChannelMessageReceiveAdapterShape,
ChannelMessageSendAdapter,
ChannelMessageSendAttemptContext,
ChannelMessageSendAttemptKind,
ChannelMessageSendCommitContext,
ChannelMessageSendFailureContext,
ChannelMessageSendLifecycleAdapter,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendResult,
ChannelMessageSendSuccessContext,
ChannelMessageSendTextContext,
ChannelMessageUnknownSendContext,
ChannelMessageUnknownSendReconciliationResult,
DeriveDurableFinalDeliveryRequirementsParams,
DurableFinalDeliveryCapability,
DurableFinalDeliveryPayloadShape,
DurableFinalDeliveryRequirementMap,
DurableFinalRequirementExtras,
DurableMessageSendIntent,
MessageSendContext,
MessageDurabilityPolicy,
LiveMessagePhase,
LiveMessageState,
LivePreviewFinalizerCapability,
LivePreviewFinalizerCapabilityMap,
MessageReceipt,
MessageReceiptPart,
MessageReceiptPartKind,
MessageReceiptSourceResult,
RenderedMessageBatch,
RenderedMessageBatchPlan,
RenderedMessageBatchPlanItem,
RenderedMessageBatchPlanKind,
} from "./types.js";

View File

@@ -0,0 +1,356 @@
import { describe, expect, it, vi } from "vitest";
import {
createLiveMessageState,
defineFinalizableLivePreviewAdapter,
deliverFinalizableLivePreview,
deliverWithFinalizableLivePreviewAdapter,
markLiveMessageCancelled,
markLiveMessageFinalized,
markLiveMessagePreviewUpdated,
} from "./live.js";
import { createMessageReceiveContext, shouldAckMessageAfterStage } from "./receive.js";
import { classifyDurableSendRecoveryState, createDurableMessageStateRecord } from "./state.js";
describe("message lifecycle primitives", () => {
it("tracks live preview finalization state", () => {
const receipt = {
primaryPlatformMessageId: "m1",
platformMessageIds: ["m1"],
parts: [],
sentAt: 123,
};
const preview = createLiveMessageState({ receipt });
expect(preview).toEqual(
expect.objectContaining({
phase: "previewing",
canFinalizeInPlace: true,
}),
);
expect(markLiveMessageFinalized(preview, receipt)).toEqual(
expect.objectContaining({
phase: "finalized",
canFinalizeInPlace: false,
}),
);
expect(markLiveMessageCancelled(preview)).toEqual(
expect.objectContaining({
phase: "cancelled",
canFinalizeInPlace: false,
}),
);
});
it("tracks live preview rendered batch updates", () => {
const preview = createLiveMessageState();
const rendered = {
payloads: [{ text: "draft" }],
plan: {
payloadCount: 1,
textCount: 1,
mediaCount: 0,
voiceCount: 0,
presentationCount: 0,
interactiveCount: 0,
channelDataCount: 0,
items: [{ index: 0, kinds: ["text"] as const, text: "draft", mediaUrls: [] }],
},
};
expect(markLiveMessagePreviewUpdated(preview, rendered)).toEqual(
expect.objectContaining({
phase: "previewing",
lastRendered: rendered,
}),
);
});
it("finalizes live previews in place with preview receipts", async () => {
const editFinal = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => undefined);
const onPreviewFinalized = vi.fn(async () => undefined);
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "done" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-1",
seal: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
deliverNormally,
onPreviewFinalized,
});
expect(result.kind).toBe("preview-finalized");
expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
expect(result.liveState).toEqual(
expect.objectContaining({
phase: "finalized",
canFinalizeInPlace: false,
receipt: expect.objectContaining({
primaryPlatformMessageId: "preview-1",
platformMessageIds: ["preview-1"],
}),
}),
);
expect(onPreviewFinalized).toHaveBeenCalledWith(
"preview-1",
expect.objectContaining({ primaryPlatformMessageId: "preview-1" }),
result.liveState,
);
});
it("treats live preview fallback delivery as terminal state", async () => {
const discardPending = vi.fn(async () => undefined);
const clear = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => true);
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "with media" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-2",
discardPending,
clear,
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
});
expect(result.kind).toBe("normal-delivered");
expect(discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" });
expect(clear).toHaveBeenCalledTimes(1);
expect(result.liveState).toEqual(
expect.objectContaining({
phase: "cancelled",
canFinalizeInPlace: false,
}),
);
});
it("delivers through finalizable live preview adapters", async () => {
const editFinal = vi.fn(async () => undefined);
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-adapter-1",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text.toUpperCase() }),
editFinal,
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally: vi.fn(async () => undefined),
});
expect(result.kind).toBe("preview-finalized");
expect(editFinal).toHaveBeenCalledWith("preview-adapter-1", { text: "DONE" });
});
it("lets live preview adapters resolve the committed platform id after final edit", async () => {
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-before-edit",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
resolveFinalizedId: () => "message-after-edit",
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally: vi.fn(async () => undefined),
});
expect(result.liveState?.receipt?.primaryPlatformMessageId).toBe("message-after-edit");
});
it("falls back to normal delivery when no live preview adapter is available", async () => {
const deliverNormally = vi.fn(async () => undefined);
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "plain" },
deliverNormally,
});
expect(result.kind).toBe("normal-delivered");
expect(deliverNormally).toHaveBeenCalledWith({ text: "plain" });
});
it("lets live preview adapters retain ambiguous failed final edits without fallback send", async () => {
const deliverNormally = vi.fn(async () => undefined);
const handlePreviewEditError = vi.fn(() => "retain" as const);
const editError = new Error("timeout after request");
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-maybe-final",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
editFinal: vi.fn(async () => {
throw editError;
}),
handlePreviewEditError,
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally,
});
expect(result.kind).toBe("preview-retained");
expect(result.liveState?.phase).toBe("previewing");
expect(deliverNormally).not.toHaveBeenCalled();
expect(handlePreviewEditError).toHaveBeenCalledWith(
expect.objectContaining({
error: editError,
id: "preview-maybe-final",
edit: { text: "done" },
payload: { text: "done" },
}),
);
});
it("does not fallback-send after a successful preview edit when finalization hooks fail", async () => {
const deliverNormally = vi.fn(async () => undefined);
const onPreviewFinalized = vi.fn(async () => {
throw new Error("receipt side effect failed");
});
const editFinal = vi.fn(async () => undefined);
await expect(
deliverFinalizableLivePreview({
kind: "final",
payload: { text: "done" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-finalized-before-hook",
seal: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
deliverNormally,
onPreviewFinalized,
}),
).rejects.toThrow("receipt side effect failed");
expect(editFinal).toHaveBeenCalledWith("preview-finalized-before-hook", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
});
it("creates receive contexts with explicit ack policy defaults", () => {
const ctx = createMessageReceiveContext({
id: "rx-1",
channel: "telegram",
message: { text: "hello" },
receivedAt: 123,
});
expect(ctx).toEqual(
expect.objectContaining({
id: "rx-1",
channel: "telegram",
message: { text: "hello" },
ackPolicy: "after_receive_record",
ackState: "pending",
receivedAt: 123,
}),
);
});
it("acks and nacks receive contexts through explicit hooks", async () => {
const onAck = vi.fn(async () => undefined);
const onNack = vi.fn(async () => undefined);
const ctx = createMessageReceiveContext({
id: "rx-ack",
channel: "telegram",
message: { text: "hello" },
ackPolicy: "after_durable_send",
onAck,
onNack,
});
expect(ctx.shouldAckAfter("receive_record")).toBe(false);
expect(ctx.shouldAckAfter("durable_send")).toBe(true);
await ctx.ack();
await ctx.ack();
expect(onAck).toHaveBeenCalledTimes(1);
expect(ctx.ackState).toBe("acked");
expect(ctx.ackedAt).toEqual(expect.any(Number));
await ctx.nack(new Error("offset failed"));
expect(onNack).toHaveBeenCalledWith(expect.any(Error));
expect(ctx.ackState).toBe("nacked");
expect(ctx.nackErrorMessage).toBe("offset failed");
});
it("maps ack policies to lifecycle stages", () => {
expect(shouldAckMessageAfterStage("after_receive_record", "receive_record")).toBe(true);
expect(shouldAckMessageAfterStage("after_receive_record", "agent_dispatch")).toBe(false);
expect(shouldAckMessageAfterStage("after_agent_dispatch", "agent_dispatch")).toBe(true);
expect(shouldAckMessageAfterStage("after_durable_send", "durable_send")).toBe(true);
expect(shouldAckMessageAfterStage("manual", "manual")).toBe(false);
});
it("classifies unknown-after-send recovery only after platform send may have started", () => {
expect(
classifyDurableSendRecoveryState({
hasIntent: true,
hasReceipt: false,
platformSendMayHaveStarted: true,
}),
).toBe("unknown_after_send");
expect(
classifyDurableSendRecoveryState({
hasIntent: true,
hasReceipt: false,
platformSendMayHaveStarted: false,
}),
).toBe("pending");
});
it("creates durable message state records with normalized errors", () => {
expect(
createDurableMessageStateRecord({
intent: {
id: "intent-1",
channel: "telegram",
to: "12345",
durability: "required",
},
state: "failed",
error: new Error("network"),
updatedAt: 123,
}),
).toEqual(
expect.objectContaining({
state: "failed",
errorMessage: "network",
updatedAt: 123,
}),
);
});
});

View File

@@ -0,0 +1,273 @@
import type { LiveMessageState, MessageReceipt, RenderedMessageBatch } from "./types.js";
export type { LiveMessagePhase, LiveMessageState } from "./types.js";
export type LivePreviewFinalizerDraft<TId> = {
flush: () => Promise<void>;
id: () => TId | undefined;
seal?: () => Promise<void>;
discardPending?: () => Promise<void>;
clear: () => Promise<void>;
};
export type LivePreviewFinalizerResultKind =
| "normal-delivered"
| "normal-skipped"
| "preview-finalized"
| "preview-retained";
export type LivePreviewFinalizerResult<TPayload> = {
kind: LivePreviewFinalizerResultKind;
liveState?: LiveMessageState<TPayload>;
};
export type FinalizableLivePreviewAdapter<TPayload, TId, TEdit> = {
draft?: LivePreviewFinalizerDraft<TId>;
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
editFinal: (id: TId, edit: TEdit) => Promise<void>;
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
onPreviewFinalized?: (
id: TId,
receipt: MessageReceipt,
liveState: LiveMessageState<TPayload>,
) => Promise<void> | void;
handlePreviewEditError?: (params: {
error: unknown;
id: TId;
edit: TEdit;
payload: TPayload;
liveState: LiveMessageState<TPayload>;
}) => "fallback" | "retain" | Promise<"fallback" | "retain">;
logPreviewEditFailure?: (error: unknown) => void;
};
export function defineFinalizableLivePreviewAdapter<TPayload, TId, TEdit>(
adapter: FinalizableLivePreviewAdapter<TPayload, TId, TEdit>,
): FinalizableLivePreviewAdapter<TPayload, TId, TEdit> {
return adapter;
}
export function createLiveMessageState<TPayload = unknown>(params?: {
receipt?: MessageReceipt;
lastRendered?: RenderedMessageBatch<TPayload>;
canFinalizeInPlace?: boolean;
}): LiveMessageState<TPayload> {
return {
phase: params?.receipt ? "previewing" : "idle",
canFinalizeInPlace: params?.canFinalizeInPlace ?? Boolean(params?.receipt),
...(params?.receipt ? { receipt: params.receipt } : {}),
...(params?.lastRendered ? { lastRendered: params.lastRendered } : {}),
};
}
export function markLiveMessageFinalized<TPayload>(
state: LiveMessageState<TPayload>,
receipt: MessageReceipt,
): LiveMessageState<TPayload> {
return {
...state,
phase: "finalized",
receipt,
canFinalizeInPlace: false,
};
}
export function createPreviewMessageReceipt(params: {
id: unknown;
threadId?: string;
replyToId?: string;
sentAt?: number;
raw?: unknown;
}): MessageReceipt {
const platformMessageId = String(params.id);
return {
primaryPlatformMessageId: platformMessageId,
platformMessageIds: [platformMessageId],
parts: [
{
platformMessageId,
kind: "preview",
index: 0,
...(params.threadId ? { threadId: params.threadId } : {}),
...(params.replyToId ? { replyToId: params.replyToId } : {}),
},
],
...(params.threadId ? { threadId: params.threadId } : {}),
...(params.replyToId ? { replyToId: params.replyToId } : {}),
sentAt: params.sentAt ?? Date.now(),
...(params.raw === undefined ? {} : { raw: [{ meta: { raw: params.raw } }] }),
};
}
export async function deliverFinalizableLivePreview<TPayload, TId, TEdit>(params: {
kind: "tool" | "block" | "final";
payload: TPayload;
liveState?: LiveMessageState<TPayload>;
draft?: LivePreviewFinalizerDraft<TId>;
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
editFinal: (id: TId, edit: TEdit) => Promise<void>;
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
onPreviewFinalized?: (
id: TId,
receipt: MessageReceipt,
liveState: LiveMessageState<TPayload>,
) => Promise<void> | void;
handlePreviewEditError?: (params: {
error: unknown;
id: TId;
edit: TEdit;
payload: TPayload;
liveState: LiveMessageState<TPayload>;
}) => "fallback" | "retain" | Promise<"fallback" | "retain">;
onNormalDelivered?: () => Promise<void> | void;
logPreviewEditFailure?: (error: unknown) => void;
}): Promise<LivePreviewFinalizerResult<TPayload>> {
let liveState =
params.liveState ??
createLiveMessageState<TPayload>({ canFinalizeInPlace: Boolean(params.draft) });
if (params.kind !== "final" || !params.draft) {
const delivered = await params.deliverNormally(params.payload);
if (delivered === false) {
return { kind: "normal-skipped", liveState };
}
await params.onNormalDelivered?.();
return { kind: "normal-delivered", liveState };
}
const edit = liveState.canFinalizeInPlace ? params.buildFinalEdit(params.payload) : undefined;
if (edit !== undefined) {
await params.draft.flush();
const previewId = params.draft.id();
if (previewId !== undefined) {
await params.draft.seal?.();
let editSucceeded = false;
try {
await params.editFinal(previewId, edit);
editSucceeded = true;
} catch (err) {
params.logPreviewEditFailure?.(err);
const decision =
(await params.handlePreviewEditError?.({
error: err,
id: previewId,
edit,
payload: params.payload,
liveState,
})) ?? "fallback";
if (decision === "retain") {
const receipt =
liveState.receipt ??
params.createPreviewReceipt?.(previewId, edit) ??
createPreviewMessageReceipt({ id: previewId });
liveState = {
...liveState,
phase: "previewing",
canFinalizeInPlace: true,
receipt,
};
return { kind: "preview-retained", liveState };
}
}
if (editSucceeded) {
const finalizedId = params.resolveFinalizedId?.(previewId, edit) ?? previewId;
const receipt =
params.createPreviewReceipt?.(finalizedId, edit) ??
createPreviewMessageReceipt({ id: finalizedId });
liveState = markLiveMessageFinalized(liveState, receipt);
await params.onPreviewFinalized?.(finalizedId, receipt, liveState);
return { kind: "preview-finalized", liveState };
}
}
}
if (params.draft.discardPending) {
await params.draft.discardPending();
} else {
await params.draft.clear();
}
liveState = markLiveMessageCancelled(liveState);
let delivered = false;
try {
const result = await params.deliverNormally(params.payload);
delivered = result !== false;
if (delivered) {
await params.onNormalDelivered?.();
}
} finally {
if (delivered) {
await params.draft.clear();
}
}
return { kind: delivered ? "normal-delivered" : "normal-skipped", liveState };
}
export async function deliverWithFinalizableLivePreviewAdapter<TPayload, TId, TEdit>(params: {
kind: "tool" | "block" | "final";
payload: TPayload;
liveState?: LiveMessageState<TPayload>;
adapter?: FinalizableLivePreviewAdapter<TPayload, TId, TEdit>;
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
onNormalDelivered?: () => Promise<void> | void;
}): Promise<LivePreviewFinalizerResult<TPayload>> {
if (!params.adapter) {
const liveState = params.liveState ?? createLiveMessageState<TPayload>();
const delivered = await params.deliverNormally(params.payload);
if (delivered === false) {
return { kind: "normal-skipped", liveState };
}
await params.onNormalDelivered?.();
return { kind: "normal-delivered", liveState };
}
return await deliverFinalizableLivePreview({
kind: params.kind,
payload: params.payload,
...(params.liveState ? { liveState: params.liveState } : {}),
draft: params.adapter.draft,
buildFinalEdit: params.adapter.buildFinalEdit,
editFinal: params.adapter.editFinal,
...(params.adapter.resolveFinalizedId
? { resolveFinalizedId: params.adapter.resolveFinalizedId }
: {}),
deliverNormally: params.deliverNormally,
...(params.adapter.createPreviewReceipt
? { createPreviewReceipt: params.adapter.createPreviewReceipt }
: {}),
...(params.adapter.onPreviewFinalized
? { onPreviewFinalized: params.adapter.onPreviewFinalized }
: {}),
...(params.adapter.handlePreviewEditError
? { handlePreviewEditError: params.adapter.handlePreviewEditError }
: {}),
...(params.onNormalDelivered ? { onNormalDelivered: params.onNormalDelivered } : {}),
...(params.adapter.logPreviewEditFailure
? { logPreviewEditFailure: params.adapter.logPreviewEditFailure }
: {}),
});
}
export function markLiveMessagePreviewUpdated<TPayload>(
state: LiveMessageState<TPayload>,
rendered: RenderedMessageBatch<TPayload>,
): LiveMessageState<TPayload> {
return {
...state,
phase: "previewing",
lastRendered: rendered,
};
}
export function markLiveMessageCancelled<TPayload>(
state: LiveMessageState<TPayload>,
): LiveMessageState<TPayload> {
return {
...state,
phase: "cancelled",
canFinalizeInPlace: false,
};
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js";
import type { MessageReceipt } from "./types.js";
const cfg = {} as OpenClawConfig;
describe("createChannelMessageAdapterFromOutbound", () => {
it("wraps outbound text sends with a message receipt", async () => {
const sendText = vi.fn(async () => ({ channel: "demo", messageId: "msg-1" }));
const adapter = createChannelMessageAdapterFromOutbound({
id: "demo",
outbound: {
deliveryCapabilities: { durableFinal: { text: true, replyTo: true } },
sendText,
},
});
const result = await adapter.send?.text?.({
cfg,
to: "room-1",
text: "hello",
replyToId: "parent-1",
threadId: "thread-1",
});
expect(adapter).toEqual(
expect.objectContaining({
id: "demo",
durableFinal: { capabilities: { text: true, replyTo: true } },
}),
);
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
to: "room-1",
text: "hello",
replyToId: "parent-1",
threadId: "thread-1",
}),
);
expect(result).toEqual({
messageId: "msg-1",
receipt: expect.objectContaining({
primaryPlatformMessageId: "msg-1",
platformMessageIds: ["msg-1"],
threadId: "thread-1",
replyToId: "parent-1",
parts: [
expect.objectContaining({
platformMessageId: "msg-1",
kind: "text",
threadId: "thread-1",
replyToId: "parent-1",
}),
],
}),
});
});
it("preserves an outbound receipt instead of rebuilding it", async () => {
const receipt: MessageReceipt = {
primaryPlatformMessageId: "receipt-1",
platformMessageIds: ["receipt-1", "receipt-2"],
parts: [
{ platformMessageId: "receipt-1", kind: "media", index: 0 },
{ platformMessageId: "receipt-2", kind: "media", index: 1 },
],
sentAt: 123,
};
const adapter = createChannelMessageAdapterFromOutbound({
outbound: {
deliveryCapabilities: { durableFinal: { media: true } },
sendMedia: vi.fn(async () => ({ channel: "demo", messageId: "legacy-id", receipt })),
},
});
await expect(
adapter.send?.media?.({
cfg,
to: "room-1",
text: "caption",
mediaUrl: "file:///tmp/a.png",
}),
).resolves.toEqual({ messageId: "legacy-id", receipt });
});
it("wraps rich payload sends and infers the receipt part kind", async () => {
const sendPayload = vi.fn(async () => ({ channel: "demo", messageId: "card-1" }));
const adapter = createChannelMessageAdapterFromOutbound({
capabilities: { payload: true, batch: true },
outbound: { sendPayload },
});
const result = await adapter.send?.payload?.({
cfg,
to: "room-1",
text: "",
payload: {
presentation: { blocks: [{ type: "text", text: "ready" }] },
},
});
expect(adapter.durableFinal?.capabilities).toEqual({ payload: true, batch: true });
expect(sendPayload).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
presentation: { blocks: [{ type: "text", text: "ready" }] },
},
}),
);
expect(result?.receipt.parts[0]).toEqual(
expect.objectContaining({ platformMessageId: "card-1", kind: "card" }),
);
});
it("exposes only send methods backed by outbound handlers", () => {
const adapter = createChannelMessageAdapterFromOutbound({
outbound: {
sendText: vi.fn(async () => ({ messageId: "msg-1" })),
},
});
expect(adapter.send?.text).toEqual(expect.any(Function));
expect(adapter.send?.media).toBeUndefined();
expect(adapter.send?.payload).toBeUndefined();
});
it("defaults outbound-derived adapters to plugin-owned receive acknowledgements", () => {
const adapter = createChannelMessageAdapterFromOutbound({
outbound: {
sendText: vi.fn(async () => ({ messageId: "msg-1" })),
},
});
expect(adapter.receive).toEqual({
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
});
});
it("preserves declared live and receive lifecycle metadata", () => {
const adapter = createChannelMessageAdapterFromOutbound({
outbound: {},
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
},
},
receive: {
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
},
});
expect(adapter.live).toEqual({
capabilities: {
draftPreview: true,
previewFinalization: true,
},
});
expect(adapter.receive).toEqual({
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
});
});
});

View File

@@ -0,0 +1,148 @@
import { createMessageReceiptFromOutboundResults } from "./receipt.js";
import type {
ChannelMessageAdapterShape,
ChannelMessageLiveAdapterShape,
ChannelMessageReceiveAdapterShape,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendResult,
ChannelMessageSendTextContext,
DurableFinalDeliveryRequirementMap,
MessageReceipt,
MessageReceiptPartKind,
MessageReceiptSourceResult,
} from "./types.js";
const defaultManualReceiveAdapter = {
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
} as const satisfies ChannelMessageReceiveAdapterShape;
export type ChannelMessageOutboundBridgeResult = MessageReceiptSourceResult & {
receipt?: MessageReceipt;
messageId?: string;
};
export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
deliveryCapabilities?: {
durableFinal?: DurableFinalDeliveryRequirementMap;
};
sendText?: (
ctx: ChannelMessageSendTextContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
sendMedia?: (
ctx: ChannelMessageSendMediaContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
sendPayload?: (
ctx: ChannelMessageSendPayloadContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
};
export type CreateChannelMessageAdapterFromOutboundParams<TConfig = unknown> = {
id?: string;
outbound: ChannelMessageOutboundBridgeAdapter<TConfig>;
capabilities?: DurableFinalDeliveryRequirementMap;
live?: ChannelMessageLiveAdapterShape;
receive?: ChannelMessageReceiveAdapterShape;
};
function resolveResultMessageId(result: ChannelMessageOutboundBridgeResult): string | undefined {
return (
result.messageId ??
result.receipt?.primaryPlatformMessageId ??
result.receipt?.platformMessageIds[0] ??
result.chatId ??
result.channelId ??
result.roomId ??
result.conversationId ??
result.toJid ??
result.pollId
);
}
function toMessageSendResult(
result: ChannelMessageOutboundBridgeResult,
params: {
kind: MessageReceiptPartKind;
threadId?: string | number | null;
replyToId?: string | null;
},
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: [result],
kind: params.kind,
threadId: params.threadId == null ? undefined : String(params.threadId),
replyToId: params.replyToId ?? undefined,
});
return {
receipt,
...(resolveResultMessageId({ ...result, receipt })
? {
messageId: resolveResultMessageId({ ...result, receipt }),
}
: {}),
};
}
function resolvePayloadReceiptKind(
ctx: ChannelMessageSendPayloadContext<unknown>,
): MessageReceiptPartKind {
if (
ctx.payload.audioAsVoice &&
(ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length)
) {
return "voice";
}
if (ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length) {
return "media";
}
if (ctx.payload.text?.trim() || ctx.text.trim()) {
return "text";
}
if (ctx.payload.presentation?.blocks?.length || ctx.payload.interactive) {
return "card";
}
return "unknown";
}
export function createChannelMessageAdapterFromOutbound<TConfig = unknown>(
params: CreateChannelMessageAdapterFromOutboundParams<TConfig>,
): ChannelMessageAdapterShape<TConfig> {
const send: NonNullable<ChannelMessageAdapterShape<TConfig>["send"]> = {};
if (params.outbound.sendText) {
send.text = async (ctx) =>
toMessageSendResult(await params.outbound.sendText!(ctx), {
kind: "text",
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
}
if (params.outbound.sendMedia) {
send.media = async (ctx) =>
toMessageSendResult(await params.outbound.sendMedia!(ctx), {
kind: ctx.audioAsVoice ? "voice" : "media",
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
}
if (params.outbound.sendPayload) {
send.payload = async (ctx) =>
toMessageSendResult(await params.outbound.sendPayload!(ctx), {
kind: resolvePayloadReceiptKind(ctx as ChannelMessageSendPayloadContext<unknown>),
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
}
return {
...(params.id ? { id: params.id } : {}),
durableFinal: {
capabilities: params.capabilities ?? params.outbound.deliveryCapabilities?.durableFinal,
},
send,
...(params.live ? { live: params.live } : {}),
receive: params.receive ?? defaultManualReceiveAdapter,
};
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
createMessageReceiptFromOutboundResults,
listMessageReceiptPlatformIds,
resolveMessageReceiptPrimaryId,
} from "./receipt.js";
describe("createMessageReceiptFromOutboundResults", () => {
it("builds a multi-part receipt from outbound delivery results", () => {
const receipt = createMessageReceiptFromOutboundResults({
results: [
{ channel: "telegram", messageId: "m1" },
{ channel: "telegram", messageId: "m2" },
],
kind: "text",
threadId: "topic-1",
replyToId: "reply-1",
sentAt: 123,
});
expect(receipt).toEqual(
expect.objectContaining({
primaryPlatformMessageId: "m1",
platformMessageIds: ["m1", "m2"],
threadId: "topic-1",
replyToId: "reply-1",
sentAt: 123,
}),
);
expect(receipt.parts).toEqual([
expect.objectContaining({ platformMessageId: "m1", kind: "text", index: 0 }),
expect.objectContaining({ platformMessageId: "m2", kind: "text", index: 1 }),
]);
});
it("uses alternate platform ids when messageId is unavailable", () => {
const receipt = createMessageReceiptFromOutboundResults({
results: [{ channel: "whatsapp", messageId: "", toJid: "jid-1" }],
sentAt: 123,
});
expect(receipt.primaryPlatformMessageId).toBe("jid-1");
expect(receipt.platformMessageIds).toEqual(["jid-1"]);
});
it("preserves nested platform receipts before falling back to delivery ids", () => {
const receipt = createMessageReceiptFromOutboundResults({
results: [
{
channel: "telegram",
messageId: "top-level-ignored",
receipt: {
primaryPlatformMessageId: "platform-1",
platformMessageIds: ["platform-1", "platform-2"],
parts: [
{ platformMessageId: "platform-1", kind: "text", index: 0 },
{ platformMessageId: "platform-2", kind: "media", index: 1 },
],
threadId: "native-thread",
sentAt: 123,
},
},
{ channel: "telegram", messageId: "fallback-1" },
],
kind: "text",
sentAt: 456,
});
expect(receipt.primaryPlatformMessageId).toBe("platform-1");
expect(receipt.platformMessageIds).toEqual(["platform-1", "platform-2", "fallback-1"]);
expect(receipt.parts).toEqual([
expect.objectContaining({ platformMessageId: "platform-1", kind: "text", index: 0 }),
expect.objectContaining({ platformMessageId: "platform-2", kind: "media", index: 1 }),
expect.objectContaining({ platformMessageId: "fallback-1", kind: "text", index: 1 }),
]);
expect(receipt.threadId).toBe("native-thread");
expect(receipt.sentAt).toBe(456);
});
it("normalizes receipt ids for compatibility edges", () => {
const receipt = {
primaryPlatformMessageId: " ",
platformMessageIds: [" m1 ", "", "m1", "m2"],
parts: [],
sentAt: 123,
};
expect(listMessageReceiptPlatformIds(receipt)).toEqual(["m1", "m2"]);
expect(resolveMessageReceiptPrimaryId(receipt)).toBe("m1");
});
});

View File

@@ -0,0 +1,122 @@
import type {
MessageReceipt,
MessageReceiptPartKind,
MessageReceiptSourceResult,
} from "./types.js";
type MessageReceiptInputResult = MessageReceiptSourceResult & {
receipt?: MessageReceipt;
};
function resolveReceiptMessageId(result: MessageReceiptInputResult): string | undefined {
return (
result.messageId ||
result.chatId ||
result.channelId ||
result.roomId ||
result.conversationId ||
result.toJid ||
result.pollId
);
}
function hasNestedReceiptData(receipt: MessageReceipt | undefined): receipt is MessageReceipt {
return Boolean(
receipt &&
(receipt.parts.length > 0 ||
receipt.platformMessageIds.length > 0 ||
receipt.primaryPlatformMessageId),
);
}
function appendUnique(values: string[], value: string | undefined): void {
const normalized = value?.trim();
if (normalized && !values.includes(normalized)) {
values.push(normalized);
}
}
export function createMessageReceiptFromOutboundResults(params: {
results: readonly MessageReceiptInputResult[];
kind?: MessageReceiptPartKind;
threadId?: string;
replyToId?: string;
sentAt?: number;
}): MessageReceipt {
const parts = params.results.flatMap((result, resultIndex) => {
if (hasNestedReceiptData(result.receipt)) {
return result.receipt.parts.length > 0
? result.receipt.parts.map((part, partIndex) => ({
...part,
index: part.index ?? partIndex,
...(part.threadId || !params.threadId ? {} : { threadId: params.threadId }),
...(part.replyToId || !params.replyToId ? {} : { replyToId: params.replyToId }),
}))
: result.receipt.platformMessageIds.map((platformMessageId, partIndex) => ({
platformMessageId,
kind: params.kind ?? "unknown",
index: partIndex,
...(params.threadId ? { threadId: params.threadId } : {}),
...(params.replyToId ? { replyToId: params.replyToId } : {}),
}));
}
const platformMessageId = resolveReceiptMessageId(result);
if (!platformMessageId) {
return [];
}
return [
{
platformMessageId,
kind: params.kind ?? "unknown",
index: resultIndex,
...(params.threadId ? { threadId: params.threadId } : {}),
...(params.replyToId ? { replyToId: params.replyToId } : {}),
raw: result,
},
];
});
const platformMessageIds: string[] = [];
for (const result of params.results) {
if (hasNestedReceiptData(result.receipt)) {
appendUnique(platformMessageIds, result.receipt.primaryPlatformMessageId);
for (const platformMessageId of result.receipt.platformMessageIds) {
appendUnique(platformMessageIds, platformMessageId);
}
for (const part of result.receipt.parts) {
appendUnique(platformMessageIds, part.platformMessageId);
}
continue;
}
appendUnique(platformMessageIds, resolveReceiptMessageId(result));
}
const firstNestedReceipt = params.results.find((result) =>
hasNestedReceiptData(result.receipt),
)?.receipt;
return {
...(platformMessageIds[0] ? { primaryPlatformMessageId: platformMessageIds[0] } : {}),
platformMessageIds,
parts,
...((params.threadId ?? firstNestedReceipt?.threadId)
? { threadId: params.threadId ?? firstNestedReceipt?.threadId }
: {}),
...((params.replyToId ?? firstNestedReceipt?.replyToId)
? { replyToId: params.replyToId ?? firstNestedReceipt?.replyToId }
: {}),
sentAt: params.sentAt ?? firstNestedReceipt?.sentAt ?? Date.now(),
raw: params.results,
};
}
export function listMessageReceiptPlatformIds(receipt: MessageReceipt): string[] {
return Array.from(
new Set(receipt.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)),
);
}
export function resolveMessageReceiptPrimaryId(receipt: MessageReceipt): string | undefined {
const primary = receipt.primaryPlatformMessageId?.trim();
if (primary) {
return primary;
}
return listMessageReceiptPlatformIds(receipt)[0];
}

View File

@@ -0,0 +1,85 @@
import type { ChannelMessageReceiveAckPolicy } from "./types.js";
export type MessageAckPolicy = ChannelMessageReceiveAckPolicy;
export type MessageAckStage = "receive_record" | "agent_dispatch" | "durable_send" | "manual";
export type MessageAckState = "pending" | "acked" | "nacked";
export type MessageReceiveContext<TMessage = unknown> = {
id: string;
channel: string;
accountId?: string;
message: TMessage;
ackPolicy: MessageAckPolicy;
ackState: MessageAckState;
ackedAt?: number;
nackErrorMessage?: string;
receivedAt: number;
signal: AbortSignal;
shouldAckAfter(stage: MessageAckStage): boolean;
ack(): Promise<void>;
nack(error: unknown): Promise<void>;
};
const neverAbortedSignal = new AbortController().signal;
export function shouldAckMessageAfterStage(
policy: MessageAckPolicy,
stage: MessageAckStage,
): boolean {
switch (policy) {
case "after_receive_record":
return stage === "receive_record";
case "after_agent_dispatch":
return stage === "agent_dispatch";
case "after_durable_send":
return stage === "durable_send";
case "manual":
return false;
}
return false;
}
function normalizeAckErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createMessageReceiveContext<TMessage>(params: {
id: string;
channel: string;
accountId?: string;
message: TMessage;
ackPolicy?: MessageAckPolicy;
receivedAt?: number;
signal?: AbortSignal;
onAck?: () => Promise<void> | void;
onNack?: (error: unknown) => Promise<void> | void;
}): MessageReceiveContext<TMessage> {
const ctx: MessageReceiveContext<TMessage> = {
id: params.id,
channel: params.channel,
...(params.accountId ? { accountId: params.accountId } : {}),
message: params.message,
ackPolicy: params.ackPolicy ?? "after_receive_record",
ackState: "pending",
receivedAt: params.receivedAt ?? Date.now(),
signal: params.signal ?? neverAbortedSignal,
shouldAckAfter: (stage) => shouldAckMessageAfterStage(ctx.ackPolicy, stage),
ack: async () => {
if (ctx.ackState === "acked") {
return;
}
await params.onAck?.();
ctx.ackState = "acked";
ctx.ackedAt = Date.now();
delete ctx.nackErrorMessage;
},
nack: async (error) => {
await params.onNack?.(error);
ctx.ackState = "nacked";
ctx.nackErrorMessage = normalizeAckErrorMessage(error);
},
};
return ctx;
}

View File

@@ -0,0 +1,93 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type {
RenderedMessageBatch,
RenderedMessageBatchPlan,
RenderedMessageBatchPlanItem,
RenderedMessageBatchPlanKind,
} from "./types.js";
function countMedia(payload: ReplyPayload): number {
return (payload.mediaUrls?.filter(Boolean).length ?? 0) + (payload.mediaUrl ? 1 : 0);
}
function collectMediaUrls(payload: ReplyPayload): string[] {
return [payload.mediaUrl, ...(payload.mediaUrls ?? [])]
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
}
function createRenderedMessageBatchPlanItem(
payload: ReplyPayload,
index: number,
): RenderedMessageBatchPlanItem {
const text = payload.text?.trim();
const mediaUrls = collectMediaUrls(payload);
const presentationBlockCount = payload.presentation?.blocks?.length ?? 0;
const kinds: RenderedMessageBatchPlanKind[] = [];
if (text) {
kinds.push("text");
}
if (mediaUrls.length > 0) {
kinds.push(payload.audioAsVoice ? "voice" : "media");
}
if (presentationBlockCount > 0) {
kinds.push("presentation");
}
if (payload.interactive) {
kinds.push("interactive");
}
if (payload.channelData) {
kinds.push("channelData");
}
return {
index,
kinds: kinds.length > 0 ? kinds : ["empty"],
...(text ? { text } : {}),
mediaUrls,
...(payload.audioAsVoice && mediaUrls.length > 0 ? { audioAsVoice: true } : {}),
...(presentationBlockCount > 0 ? { presentationBlockCount } : {}),
...(payload.interactive ? { hasInteractive: true } : {}),
...(payload.channelData ? { hasChannelData: true } : {}),
};
}
export function createRenderedMessageBatchPlan(
payloads: readonly ReplyPayload[],
): RenderedMessageBatchPlan {
const items = payloads.map(createRenderedMessageBatchPlanItem);
return payloads.reduce<RenderedMessageBatchPlan>(
(plan, payload) => {
const text = payload.text?.trim();
const mediaCount = countMedia(payload);
return {
payloadCount: plan.payloadCount + 1,
textCount: plan.textCount + (text ? 1 : 0),
mediaCount: plan.mediaCount + mediaCount,
voiceCount: plan.voiceCount + (payload.audioAsVoice && mediaCount > 0 ? 1 : 0),
presentationCount: plan.presentationCount + (payload.presentation?.blocks?.length ? 1 : 0),
interactiveCount: plan.interactiveCount + (payload.interactive ? 1 : 0),
channelDataCount: plan.channelDataCount + (payload.channelData ? 1 : 0),
items: plan.items,
};
},
{
payloadCount: 0,
textCount: 0,
mediaCount: 0,
voiceCount: 0,
presentationCount: 0,
interactiveCount: 0,
channelDataCount: 0,
items,
},
);
}
export function createRenderedMessageBatch(
payloads: ReplyPayload[],
): RenderedMessageBatch<ReplyPayload> {
return {
payloads,
plan: createRenderedMessageBatchPlan(payloads),
};
}

View File

@@ -0,0 +1,91 @@
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import {
resolveSourceReplyDeliveryMode,
type SourceReplyDeliveryModeContext,
} from "../../auto-reply/reply/source-reply-delivery-mode.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getChannelPlugin, normalizeChannelId } from "../plugins/index.js";
import {
createReplyPrefixContext,
createReplyPrefixOptions,
type ReplyPrefixContextBundle,
type ReplyPrefixOptions,
} from "../reply-prefix.js";
import {
createTypingCallbacks,
type CreateTypingCallbacksParams,
type TypingCallbacks,
} from "../typing.js";
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
export type { CreateTypingCallbacksParams, TypingCallbacks };
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
export type { SourceReplyDeliveryMode };
export function resolveChannelSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
}): SourceReplyDeliveryMode {
return resolveSourceReplyDeliveryMode(params);
}
export type ChannelReplyPipeline = ReplyPrefixOptions & {
typingCallbacks?: TypingCallbacks;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
};
export type CreateChannelReplyPipelineParams = {
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
agentId: string;
channel?: string;
accountId?: string;
typing?: CreateTypingCallbacksParams;
typingCallbacks?: TypingCallbacks;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
};
export function createChannelReplyPipeline(
params: CreateChannelReplyPipelineParams,
): ChannelReplyPipeline {
const channelId = params.channel
? (normalizeChannelId(params.channel) ?? params.channel)
: undefined;
let plugin: ReturnType<typeof getChannelPlugin> | undefined;
let pluginTransformResolved = false;
const resolvePluginTransform = () => {
if (pluginTransformResolved) {
return plugin?.messaging?.transformReplyPayload;
}
pluginTransformResolved = true;
plugin = channelId ? getChannelPlugin(channelId) : undefined;
return plugin?.messaging?.transformReplyPayload;
};
const transformReplyPayload = params.transformReplyPayload
? params.transformReplyPayload
: channelId
? (payload: ReplyPayload) =>
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
: undefined;
return {
...createReplyPrefixOptions({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
}),
...(transformReplyPayload ? { transformReplyPayload } : {}),
...(params.typingCallbacks
? { typingCallbacks: params.typingCallbacks }
: params.typing
? { typingCallbacks: createTypingCallbacks(params.typing) }
: {}),
};
}

View File

@@ -0,0 +1,7 @@
export { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js";
export type {
DurableMessageBatchSendParams,
DurableMessageBatchSendResult,
DurableMessageSendContext,
DurableMessageSendContextParams,
} from "./send.js";

View File

@@ -0,0 +1,451 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OutboundDeliveryIntent } from "../../infra/outbound/deliver.js";
const deliverOutboundPayloads = vi.hoisted(() => vi.fn());
vi.mock("../../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads,
}));
import { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js";
type DeliveryIntentCallbackParams = {
onDeliveryIntent?: (intent: OutboundDeliveryIntent) => void;
};
const cfg = {} as OpenClawConfig;
describe("withDurableMessageSendContext", () => {
it("renders and sends through a durable send context", async () => {
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
params.onDeliveryIntent?.({
id: "intent-1",
channel: "telegram",
to: "chat-1",
queuePolicy: "required",
});
return [{ channel: "telegram", messageId: "msg-1" }];
});
const result = await withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
threadId: 42,
replyToId: "reply-1",
},
async (ctx) => {
expect(ctx).toEqual(
expect.objectContaining({
id: "telegram:chat-1",
channel: "telegram",
to: "chat-1",
durability: "required",
attempt: 1,
}),
);
const rendered = await ctx.render();
expect(rendered).toEqual({
payloads: [{ text: "hello" }],
plan: expect.objectContaining({
payloadCount: 1,
textCount: 1,
mediaCount: 0,
items: [{ index: 0, kinds: ["text"] as const, text: "hello", mediaUrls: [] }],
}),
});
const send = await ctx.send(rendered);
expect(ctx.intent).toEqual(
expect.objectContaining({
id: "intent-1",
channel: "telegram",
to: "chat-1",
durability: "required",
renderedBatch: rendered,
}),
);
return send;
},
);
expect(result).toEqual(
expect.objectContaining({
status: "sent",
deliveryIntent: expect.objectContaining({ id: "intent-1" }),
receipt: expect.objectContaining({
platformMessageIds: ["msg-1"],
threadId: "42",
replyToId: "reply-1",
}),
}),
);
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
queuePolicy: "required",
payloads: [{ text: "hello" }],
threadId: 42,
replyToId: "reply-1",
}),
);
});
it("records a replayable rendered batch plan on the durable intent", async () => {
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
params.onDeliveryIntent?.({
id: "intent-media",
channel: "telegram",
to: "chat-1",
queuePolicy: "required",
});
return [{ channel: "telegram", messageId: "media-1" }];
});
let intent: unknown;
await withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [
{
text: "caption",
mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"],
audioAsVoice: true,
presentation: { blocks: [{ type: "text", text: "card" }] },
interactive: { blocks: [{ type: "buttons", buttons: [{ label: "OK" }] }] },
channelData: { native: true },
},
],
},
async (ctx) => {
const rendered = await ctx.render();
await ctx.send(rendered);
intent = ctx.intent;
},
);
expect(intent).toEqual(
expect.objectContaining({
renderedBatch: expect.objectContaining({
plan: {
payloadCount: 1,
textCount: 1,
mediaCount: 2,
voiceCount: 1,
presentationCount: 1,
interactiveCount: 1,
channelDataCount: 1,
items: [
{
index: 0,
kinds: ["text", "voice", "presentation", "interactive", "channelData"] as const,
text: "caption",
mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"],
audioAsVoice: true,
presentationBlockCount: 1,
hasInteractive: true,
hasChannelData: true,
},
],
},
}),
}),
);
});
it("forwards the durable send context signal to outbound delivery", async () => {
const abortController = new AbortController();
deliverOutboundPayloads.mockImplementationOnce(
async (params: DeliveryIntentCallbackParams & { abortSignal?: AbortSignal }) => {
expect(params.abortSignal).toBe(abortController.signal);
return [{ channel: "telegram", messageId: "msg-1" }];
},
);
const result = await sendDurableMessageBatch({
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
signal: abortController.signal,
});
expect(result).toEqual(
expect.objectContaining({
status: "sent",
receipt: expect.objectContaining({
platformMessageIds: ["msg-1"],
}),
}),
);
expect(deliverOutboundPayloads).toHaveBeenLastCalledWith(
expect.objectContaining({
abortSignal: abortController.signal,
queuePolicy: "required",
}),
);
});
it("maps best-effort durability to best-effort queue policy", async () => {
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
params.onDeliveryIntent?.({
id: "intent-best-effort",
channel: "telegram",
to: "chat-1",
queuePolicy: "best_effort",
});
return [{ channel: "telegram", messageId: "msg-1" }];
});
const result = await sendDurableMessageBatch({
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
durability: "best_effort",
});
expect(result).toEqual(
expect.objectContaining({
status: "sent",
deliveryIntent: expect.objectContaining({ id: "intent-best-effort" }),
}),
);
expect(deliverOutboundPayloads).toHaveBeenLastCalledWith(
expect.objectContaining({
queuePolicy: "best_effort",
}),
);
});
it("preserves adapter-provided multipart receipts in durable sends", async () => {
deliverOutboundPayloads.mockResolvedValueOnce([
{
channel: "telegram",
messageId: "top-level-ignored",
receipt: {
primaryPlatformMessageId: "platform-1",
platformMessageIds: ["platform-1", "platform-2"],
parts: [
{ platformMessageId: "platform-1", kind: "text", index: 0 },
{ platformMessageId: "platform-2", kind: "media", index: 1 },
],
sentAt: 123,
},
},
]);
const result = await sendDurableMessageBatch({
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
});
expect(result).toEqual(
expect.objectContaining({
status: "sent",
receipt: expect.objectContaining({
primaryPlatformMessageId: "platform-1",
platformMessageIds: ["platform-1", "platform-2"],
parts: [
expect.objectContaining({ platformMessageId: "platform-1", kind: "text" }),
expect.objectContaining({ platformMessageId: "platform-2", kind: "media" }),
],
}),
}),
);
});
it("supports preview, edit, and delete send-context hooks", async () => {
const receipt = {
primaryPlatformMessageId: "preview-1",
platformMessageIds: ["preview-1"],
parts: [],
sentAt: 123,
};
const editedReceipt = {
...receipt,
primaryPlatformMessageId: "preview-1-edited",
platformMessageIds: ["preview-1-edited"],
};
const onEditReceipt = vi.fn(async () => editedReceipt);
const onDeleteReceipt = vi.fn(async () => undefined);
await withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "final" }],
preview: {
phase: "previewing",
canFinalizeInPlace: true,
receipt,
},
onEditReceipt,
onDeleteReceipt,
},
async (ctx) => {
const rendered = await ctx.render();
const preview = await ctx.previewUpdate(rendered);
expect(preview.lastRendered).toBe(rendered);
await expect(ctx.edit(receipt, rendered)).resolves.toBe(editedReceipt);
await ctx.delete(editedReceipt);
},
);
expect(onEditReceipt).toHaveBeenCalledWith(
receipt,
expect.objectContaining({ payloads: [{ text: "final" }] }),
);
expect(onDeleteReceipt).toHaveBeenCalledWith(editedReceipt);
});
it("fails explicit edit and delete operations without a live adapter", async () => {
const receipt = {
primaryPlatformMessageId: "preview-1",
platformMessageIds: ["preview-1"],
parts: [],
sentAt: 123,
};
await withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "final" }],
},
async (ctx) => {
const rendered = await ctx.render();
await expect(ctx.edit(receipt, rendered)).rejects.toThrow(
"message send context edit is not configured",
);
await expect(ctx.delete(receipt)).rejects.toThrow(
"message send context delete is not configured",
);
},
);
});
it("treats no visible outbound result as a committed suppressed send", async () => {
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
params.onDeliveryIntent?.({
id: "intent-2",
channel: "whatsapp",
to: "jid-1",
queuePolicy: "required",
});
return [];
});
const onCommitReceipt = vi.fn();
const result = await sendDurableMessageBatch({
cfg,
channel: "whatsapp",
to: "jid-1",
payloads: [{ text: "hidden" }],
onCommitReceipt,
});
expect(result).toEqual(
expect.objectContaining({
status: "suppressed",
reason: "no_visible_result",
deliveryIntent: expect.objectContaining({ id: "intent-2" }),
}),
);
expect(onCommitReceipt).toHaveBeenCalledWith(
expect.objectContaining({
platformMessageIds: [],
}),
);
});
it("runs the failure hook when send-context orchestration throws", async () => {
const onSendFailure = vi.fn();
const error = new Error("boom");
await expect(
withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
onSendFailure,
},
async () => {
throw error;
},
),
).rejects.toThrow("boom");
expect(onSendFailure).toHaveBeenCalledWith(error);
});
it("preserves orchestration errors when the failure hook throws", async () => {
const onSendFailure = vi.fn(async () => {
throw new Error("cleanup failed");
});
const error = new Error("boom");
await expect(
withDurableMessageSendContext(
{
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
onSendFailure,
},
async () => {
throw error;
},
),
).rejects.toThrow("boom");
expect(onSendFailure).toHaveBeenCalledWith(error);
});
it("runs the failure hook when durable outbound delivery fails", async () => {
const error = new Error("send failed");
deliverOutboundPayloads.mockRejectedValueOnce(error);
const onSendFailure = vi.fn();
const result = await sendDurableMessageBatch({
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
onSendFailure,
});
expect(result).toEqual({ status: "failed", error });
expect(onSendFailure).toHaveBeenCalledWith(error);
});
it("preserves failed send results when the failure hook throws", async () => {
const error = new Error("send failed");
deliverOutboundPayloads.mockRejectedValueOnce(error);
const onSendFailure = vi.fn(async () => {
throw new Error("cleanup failed");
});
const result = await sendDurableMessageBatch({
cfg,
channel: "telegram",
to: "chat-1",
payloads: [{ text: "hello" }],
onSendFailure,
});
expect(result).toEqual({ status: "failed", error });
expect(onSendFailure).toHaveBeenCalledWith(error);
});
});

View File

@@ -0,0 +1,223 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
import {
deliverOutboundPayloads,
type DeliverOutboundPayloadsParams,
type OutboundDeliveryIntent,
} from "../../infra/outbound/deliver.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { createLiveMessageState, markLiveMessagePreviewUpdated } from "./live.js";
import { createMessageReceiptFromOutboundResults } from "./receipt.js";
import { createRenderedMessageBatch } from "./rendered-batch.js";
import type {
DurableMessageSendIntent,
LiveMessageState,
MessageDurabilityPolicy,
MessageReceipt,
MessageSendContext,
RenderedMessageBatch,
} from "./types.js";
const log = createSubsystemLogger("channels/message/send");
export type DurableMessageBatchSendParams = Omit<
DeliverOutboundPayloadsParams,
"abortSignal" | "onDeliveryIntent" | "payloads" | "queuePolicy"
> & {
payloads: ReplyPayload[];
attempt?: number;
signal?: AbortSignal;
/** @deprecated Use `signal`. */
abortSignal?: AbortSignal;
previousReceipt?: MessageReceipt;
};
export type DurableMessageBatchSendResult =
| {
status: "sent";
results: OutboundDeliveryResult[];
receipt: MessageReceipt;
deliveryIntent?: OutboundDeliveryIntent;
}
| {
status: "suppressed";
results: [];
receipt: MessageReceipt;
deliveryIntent?: OutboundDeliveryIntent;
reason: "no_visible_result";
}
| { status: "failed"; error: unknown };
const neverAbortedSignal = new AbortController().signal;
function toDurableMessageIntent(
intent: OutboundDeliveryIntent,
renderedBatch: RenderedMessageBatch<ReplyPayload>,
): DurableMessageSendIntent<ReplyPayload> {
return {
id: intent.id,
channel: intent.channel,
to: intent.to,
...(intent.accountId ? { accountId: intent.accountId } : {}),
durability: intent.queuePolicy === "required" ? "required" : "best_effort",
renderedBatch,
};
}
export type DurableMessageSendContextParams = DurableMessageBatchSendParams & {
durability?: Exclude<MessageDurabilityPolicy, "disabled">;
preview?: LiveMessageState<ReplyPayload>;
onPreviewUpdate?: (
rendered: RenderedMessageBatch<ReplyPayload>,
state: LiveMessageState<ReplyPayload>,
) => Promise<LiveMessageState<ReplyPayload>> | LiveMessageState<ReplyPayload>;
onEditReceipt?: (
receipt: MessageReceipt,
rendered: RenderedMessageBatch<ReplyPayload>,
) => Promise<MessageReceipt> | MessageReceipt;
onDeleteReceipt?: (receipt: MessageReceipt) => Promise<void> | void;
onCommitReceipt?: (receipt: MessageReceipt) => Promise<void> | void;
onSendFailure?: (error: unknown) => Promise<void> | void;
};
export type DurableMessageSendContext = MessageSendContext<
ReplyPayload,
DurableMessageBatchSendResult
>;
export async function withDurableMessageSendContext<T>(
params: DurableMessageSendContextParams,
run: (ctx: DurableMessageSendContext) => Promise<T>,
): Promise<T> {
let deliveryIntent: OutboundDeliveryIntent | undefined;
const {
attempt,
durability,
onDeleteReceipt,
onEditReceipt,
onCommitReceipt,
onPreviewUpdate,
onSendFailure,
payloads,
preview,
previousReceipt,
signal,
abortSignal,
...deliveryParams
} = params;
const effectiveSignal = signal ?? abortSignal;
const queuePolicy = durability === "best_effort" ? "best_effort" : "required";
let liveState = preview ?? createLiveMessageState<ReplyPayload>();
const ctx: DurableMessageSendContext = {
id: `${params.channel}:${params.to}`,
channel: params.channel,
to: params.to,
...(params.accountId ? { accountId: params.accountId } : {}),
durability: durability ?? "required",
attempt: attempt ?? 1,
signal: effectiveSignal ?? neverAbortedSignal,
...(previousReceipt ? { previousReceipt } : {}),
preview: liveState,
render: async (): Promise<RenderedMessageBatch<ReplyPayload>> =>
createRenderedMessageBatch(payloads),
previewUpdate: async (rendered): Promise<LiveMessageState<ReplyPayload>> => {
liveState = onPreviewUpdate
? await onPreviewUpdate(rendered, liveState)
: markLiveMessagePreviewUpdated(liveState, rendered);
ctx.preview = liveState;
return liveState;
},
send: async (rendered): Promise<DurableMessageBatchSendResult> => {
try {
const results = await deliverOutboundPayloads({
...deliveryParams,
payloads: rendered.payloads,
renderedBatchPlan: rendered.plan,
queuePolicy,
...(effectiveSignal ? { abortSignal: effectiveSignal } : {}),
onDeliveryIntent: (intent) => {
deliveryIntent = intent;
ctx.intent = toDurableMessageIntent(intent, rendered);
},
});
const receipt = createMessageReceiptFromOutboundResults({
results,
threadId: params.threadId == null ? undefined : String(params.threadId),
replyToId: params.replyToId ?? undefined,
});
if (results.length === 0) {
return {
status: "suppressed",
results: [],
receipt,
...(deliveryIntent ? { deliveryIntent } : {}),
reason: "no_visible_result",
};
}
return {
status: "sent",
results,
receipt,
...(deliveryIntent ? { deliveryIntent } : {}),
};
} catch (error: unknown) {
return { status: "failed", error };
}
},
edit: async (receipt, rendered): Promise<MessageReceipt> => {
if (!onEditReceipt) {
throw new Error("message send context edit is not configured");
}
const editedReceipt = await onEditReceipt(receipt, rendered);
liveState = {
...liveState,
receipt: editedReceipt,
lastRendered: rendered,
};
ctx.preview = liveState;
return editedReceipt;
},
delete: async (receipt) => {
if (!onDeleteReceipt) {
throw new Error("message send context delete is not configured");
}
await onDeleteReceipt(receipt);
},
commit: async (receipt) => {
await onCommitReceipt?.(receipt);
},
fail: async (error) => {
try {
await onSendFailure?.(error);
} catch (cleanupError: unknown) {
log.warn(
`message send failure cleanup failed; preserving original send error: ${formatErrorMessage(cleanupError)}`,
);
}
},
};
try {
const result = await run(ctx);
return result;
} catch (error: unknown) {
await ctx.fail(error);
throw error;
}
}
export async function sendDurableMessageBatch(
params: DurableMessageSendContextParams,
): Promise<DurableMessageBatchSendResult> {
return await withDurableMessageSendContext(params, async (ctx) => {
const rendered = await ctx.render();
const result = await ctx.send(rendered);
if (result.status !== "failed") {
await ctx.commit(result.receipt);
} else {
await ctx.fail(result.error);
}
return result;
});
}

View File

@@ -0,0 +1,58 @@
import type { DurableMessageSendIntent, MessageReceipt } from "./types.js";
export type DurableMessageSendState =
| "pending"
| "sent"
| "suppressed"
| "failed"
| "unknown_after_send";
export type DurableMessageStateRecord = {
intent: DurableMessageSendIntent;
state: DurableMessageSendState;
receipt?: MessageReceipt;
updatedAt: number;
errorMessage?: string;
};
export function createDurableMessageStateRecord(params: {
intent: DurableMessageSendIntent;
state?: DurableMessageSendState;
receipt?: MessageReceipt;
updatedAt?: number;
error?: unknown;
}): DurableMessageStateRecord {
return {
intent: params.intent,
state: params.state ?? (params.receipt ? "sent" : "pending"),
...(params.receipt ? { receipt: params.receipt } : {}),
updatedAt: params.updatedAt ?? Date.now(),
...(params.error === undefined ? {} : { errorMessage: normalizeErrorMessage(params.error) }),
};
}
export function classifyDurableSendRecoveryState(params: {
hasIntent: boolean;
hasReceipt: boolean;
platformSendMayHaveStarted: boolean;
failed?: boolean;
suppressed?: boolean;
}): DurableMessageSendState {
if (params.failed) {
return "failed";
}
if (params.suppressed) {
return "suppressed";
}
if (params.hasReceipt) {
return "sent";
}
if (params.hasIntent && params.platformSendMayHaveStarted) {
return "unknown_after_send";
}
return "pending";
}
function normalizeErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -0,0 +1,367 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
export type MessageDurabilityPolicy = "required" | "best_effort" | "disabled";
export const durableFinalDeliveryCapabilities = [
"text",
"media",
"payload",
"silent",
"replyTo",
"thread",
"nativeQuote",
"messageSendingHooks",
"batch",
"reconcileUnknownSend",
"afterSendSuccess",
"afterCommit",
] as const;
export type DurableFinalDeliveryCapability = (typeof durableFinalDeliveryCapabilities)[number];
export type DurableFinalDeliveryRequirementMap = Partial<
Record<DurableFinalDeliveryCapability, boolean>
>;
export type DurableFinalDeliveryPayloadShape = {
text?: string | null;
replyToId?: string | null;
mediaUrl?: string | null;
mediaUrls?: readonly (string | null | undefined)[] | null;
};
export type MessageReceiptSourceResult = {
channel?: string;
messageId?: string;
chatId?: string;
channelId?: string;
roomId?: string;
conversationId?: string;
toJid?: string;
pollId?: string;
timestamp?: number;
meta?: Record<string, unknown>;
};
export type MessageReceiptPartKind = "text" | "media" | "voice" | "card" | "preview" | "unknown";
export type MessageReceiptPart = {
platformMessageId: string;
kind: MessageReceiptPartKind;
index: number;
threadId?: string;
replyToId?: string;
raw?: MessageReceiptSourceResult;
};
export type MessageReceipt = {
primaryPlatformMessageId?: string;
platformMessageIds: string[];
parts: MessageReceiptPart[];
threadId?: string;
replyToId?: string;
editToken?: string;
deleteToken?: string;
sentAt: number;
raw?: readonly MessageReceiptSourceResult[];
};
export type RenderedMessageBatchPlanKind =
| "text"
| "media"
| "voice"
| "presentation"
| "interactive"
| "channelData"
| "empty";
export type RenderedMessageBatchPlanItem = {
index: number;
kinds: readonly RenderedMessageBatchPlanKind[];
text?: string;
mediaUrls: readonly string[];
audioAsVoice?: boolean;
presentationBlockCount?: number;
hasInteractive?: boolean;
hasChannelData?: boolean;
};
export type RenderedMessageBatchPlan = {
payloadCount: number;
textCount: number;
mediaCount: number;
voiceCount: number;
presentationCount: number;
interactiveCount: number;
channelDataCount: number;
items: readonly RenderedMessageBatchPlanItem[];
};
export type RenderedMessageBatch<TPayload = unknown> = {
payloads: TPayload[];
plan: RenderedMessageBatchPlan;
};
export type LiveMessagePhase = "idle" | "previewing" | "finalizing" | "finalized" | "cancelled";
export type LiveMessageState<TPayload = unknown> = {
phase: LiveMessagePhase;
canFinalizeInPlace: boolean;
receipt?: MessageReceipt;
lastRendered?: RenderedMessageBatch<TPayload>;
};
export type MessageSendContext<TPayload = unknown, TSendResult = unknown> = {
id: string;
channel: string;
to: string;
accountId?: string;
durability: Exclude<MessageDurabilityPolicy, "disabled">;
attempt: number;
signal: AbortSignal;
intent?: DurableMessageSendIntent;
previousReceipt?: MessageReceipt;
preview?: LiveMessageState<TPayload>;
render(): Promise<RenderedMessageBatch<TPayload>>;
previewUpdate(rendered: RenderedMessageBatch<TPayload>): Promise<LiveMessageState<TPayload>>;
send(rendered: RenderedMessageBatch<TPayload>): Promise<TSendResult>;
edit(receipt: MessageReceipt, rendered: RenderedMessageBatch<TPayload>): Promise<MessageReceipt>;
delete(receipt: MessageReceipt): Promise<void>;
commit(receipt: MessageReceipt): Promise<void>;
fail(error: unknown): Promise<void>;
};
export type ChannelMessageSendTextContext<TConfig = OpenClawConfig> = {
cfg: TConfig;
to: string;
text: string;
accountId?: string | null;
deps?: OutboundSendDeps;
replyToId?: string | null;
replyToIdSource?: "explicit" | "implicit";
replyToMode?: ReplyToMode;
threadId?: string | number | null;
silent?: boolean;
signal?: AbortSignal;
gatewayClientScopes?: readonly string[];
};
export type ChannelMessageSendMediaContext<TConfig = OpenClawConfig> =
ChannelMessageSendTextContext<TConfig> & {
mediaUrl: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
audioAsVoice?: boolean;
gifPlayback?: boolean;
forceDocument?: boolean;
};
export type ChannelMessageSendPayloadContext<TConfig = OpenClawConfig> =
ChannelMessageSendTextContext<TConfig> & {
payload: ReplyPayload;
mediaUrl?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
audioAsVoice?: boolean;
gifPlayback?: boolean;
forceDocument?: boolean;
};
export type ChannelMessageSendResult = {
receipt: MessageReceipt;
messageId?: string;
};
export type ChannelMessageSendAttemptKind = "text" | "media" | "payload";
export type ChannelMessageSendAttemptContext<TConfig = OpenClawConfig> =
| (ChannelMessageSendTextContext<TConfig> & { kind: "text" })
| (ChannelMessageSendMediaContext<TConfig> & { kind: "media" })
| (ChannelMessageSendPayloadContext<TConfig> & { kind: "payload" });
export type ChannelMessageSendSuccessContext<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = ChannelMessageSendAttemptContext<TConfig> & {
result: TSendResult;
attemptToken?: unknown;
};
export type ChannelMessageSendFailureContext<TConfig = OpenClawConfig> =
ChannelMessageSendAttemptContext<TConfig> & {
error: unknown;
attemptToken?: unknown;
};
export type ChannelMessageSendCommitContext<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = ChannelMessageSendSuccessContext<TConfig, TSendResult>;
export type ChannelMessageUnknownSendContext<TConfig = OpenClawConfig> = {
cfg: TConfig;
queueId: string;
channel: string;
to: string;
accountId?: string | null;
enqueuedAt: number;
retryCount: number;
platformSendStartedAt?: number;
payloads: readonly ReplyPayload[];
renderedBatchPlan?: RenderedMessageBatchPlan;
replyToId?: string | null;
replyToMode?: ReplyToMode;
threadId?: string | number | null;
silent?: boolean;
};
export type ChannelMessageUnknownSendReconciliationResult =
| {
status: "sent";
receipt: MessageReceipt;
messageId?: string;
}
| {
status: "not_sent";
}
| {
status: "unresolved";
error?: string;
retryable?: boolean;
};
export type ChannelMessageSendLifecycleAdapter<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext<TConfig>) => unknown;
afterSendSuccess?: (
ctx: ChannelMessageSendSuccessContext<TConfig, TSendResult>,
) => Promise<void> | void;
afterSendFailure?: (ctx: ChannelMessageSendFailureContext<TConfig>) => Promise<void> | void;
afterCommit?: (
ctx: ChannelMessageSendCommitContext<TConfig, TSendResult>,
) => Promise<void> | void;
};
export type ChannelMessageSendAdapter<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};
export type ChannelMessageDurableFinalAdapter = {
capabilities?: DurableFinalDeliveryRequirementMap;
reconcileUnknownSend?: (
ctx: ChannelMessageUnknownSendContext,
) =>
| Promise<ChannelMessageUnknownSendReconciliationResult | null>
| ChannelMessageUnknownSendReconciliationResult
| null;
};
export type ChannelMessageLiveCapability =
| "draftPreview"
| "previewFinalization"
| "progressUpdates"
| "nativeStreaming"
| "quietFinalization";
export const channelMessageLiveCapabilities = [
"draftPreview",
"previewFinalization",
"progressUpdates",
"nativeStreaming",
"quietFinalization",
] as const satisfies readonly ChannelMessageLiveCapability[];
export const livePreviewFinalizerCapabilities = [
"finalEdit",
"normalFallback",
"discardPending",
"previewReceipt",
"retainOnAmbiguousFailure",
] as const;
export type LivePreviewFinalizerCapability = (typeof livePreviewFinalizerCapabilities)[number];
export type LivePreviewFinalizerCapabilityMap = Partial<
Record<LivePreviewFinalizerCapability, boolean>
>;
export type ChannelMessageLiveFinalizerAdapterShape = {
capabilities?: LivePreviewFinalizerCapabilityMap;
};
export type ChannelMessageLiveAdapterShape = {
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
finalizer?: ChannelMessageLiveFinalizerAdapterShape;
};
export type ChannelMessageReceiveAckPolicy =
| "after_receive_record"
| "after_agent_dispatch"
| "after_durable_send"
| "manual";
export const channelMessageReceiveAckPolicies = [
"after_receive_record",
"after_agent_dispatch",
"after_durable_send",
"manual",
] as const satisfies readonly ChannelMessageReceiveAckPolicy[];
export type ChannelMessageReceiveAdapterShape = {
defaultAckPolicy?: ChannelMessageReceiveAckPolicy;
supportedAckPolicies?: readonly ChannelMessageReceiveAckPolicy[];
};
export type ChannelMessageAdapterShape<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
id?: string;
durableFinal?: ChannelMessageDurableFinalAdapter;
send?: ChannelMessageSendAdapter<TConfig, TSendResult>;
live?: ChannelMessageLiveAdapterShape;
receive?: ChannelMessageReceiveAdapterShape;
};
export type ChannelMessageAdapter<
TAdapter extends ChannelMessageAdapterShape = ChannelMessageAdapterShape,
> = TAdapter;
export type DurableFinalRequirementExtras = DurableFinalDeliveryRequirementMap;
export type DeriveDurableFinalDeliveryRequirementsParams = {
payload: DurableFinalDeliveryPayloadShape;
replyToId?: string | null;
threadId?: string | number | null;
silent?: boolean;
messageSendingHooks?: boolean;
payloadTransport?: boolean;
batch?: boolean;
reconcileUnknownSend?: boolean;
afterSendSuccess?: boolean;
afterCommit?: boolean;
extraCapabilities?: DurableFinalRequirementExtras;
};
export type DurableMessageSendIntent<TPayload = unknown> = {
id: string;
channel: string;
to: string;
accountId?: string;
durability: Exclude<MessageDurabilityPolicy, "disabled">;
renderedBatch?: RenderedMessageBatch<TPayload>;
};

View File

@@ -51,6 +51,20 @@ export type ChannelPresentationCapabilities = {
export type ChannelDeliveryCapabilities = {
pin?: boolean;
durableFinal?: {
text?: boolean;
media?: boolean;
payload?: boolean;
silent?: boolean;
replyTo?: boolean;
thread?: boolean;
nativeQuote?: boolean;
messageSendingHooks?: boolean;
batch?: boolean;
reconcileUnknownSend?: boolean;
afterSendSuccess?: boolean;
afterCommit?: boolean;
};
};
export type ChannelOutboundPayloadHint =

View File

@@ -24,6 +24,7 @@ export type {
ChannelOutboundPayloadContext,
ChannelOutboundPayloadHint,
ChannelOutboundTargetRef,
ChannelDeliveryCapabilities,
} from "./outbound.types.js";
import type {
ChannelAccountSnapshot,

View File

@@ -700,6 +700,14 @@ export type ChannelToolSend = {
threadId?: string | null;
};
export type ChannelMessagePreparedSendPayloadContext = {
ctx: ChannelMessageActionContext;
to: string;
payload: ReplyPayload;
replyToId?: string | null;
threadId?: string | number | null;
};
/** Channel-owned action surface for the shared `message` tool. */
export type ChannelMessageActionAdapter = {
/**
@@ -733,6 +741,14 @@ export type ChannelMessageActionAdapter = {
toolContext?: ChannelThreadingToolContext;
}) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
/**
* Translate generic `message(action=send)` arguments into the payload core
* should persist, retry, recover, and ack. Return null to keep the legacy
* plugin-owned action path for sends that cannot be represented durably.
*/
prepareSendPayload?: (
params: ChannelMessagePreparedSendPayloadContext,
) => ReplyPayload | null | undefined | Promise<ReplyPayload | null | undefined>;
/**
* Prefer this for channel-specific poll semantics or extra poll parameters.
* Core only parses the shared poll model when falling back to `outbound.sendPoll`.

View File

@@ -1,3 +1,4 @@
import type { ChannelMessageAdapterShape } from "../message/types.js";
import type { ChannelSetupWizard, ChannelSetupWizardAdapter } from "./setup-wizard-types.js";
import type { ChannelConfigSchema } from "./types.config.js";
export type {
@@ -85,6 +86,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
conversationBindings?: ChannelConversationBindingSupport;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
message?: ChannelMessageAdapterShape;
messaging?: ChannelMessagingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
directory?: ChannelDirectoryAdapter;

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
describe("createChannelDeliveryResultFromReceipt", () => {
it("keeps legacy messageIds while attaching the receipt", () => {
const receipt = {
primaryPlatformMessageId: "m1",
platformMessageIds: ["m1", "m2"],
parts: [],
sentAt: 123,
};
expect(
createChannelDeliveryResultFromReceipt({
receipt,
threadId: "thread-1",
replyToId: "reply-1",
visibleReplySent: true,
deliveryIntent: {
id: "intent-1",
kind: "outbound_queue",
queuePolicy: "required",
},
}),
).toEqual({
messageIds: ["m1", "m2"],
receipt,
threadId: "thread-1",
replyToId: "reply-1",
visibleReplySent: true,
deliveryIntent: {
id: "intent-1",
kind: "outbound_queue",
queuePolicy: "required",
},
});
});
it("preserves suppressed receipt results without synthetic message ids", () => {
const receipt = {
platformMessageIds: [],
parts: [],
sentAt: 123,
};
expect(
createChannelDeliveryResultFromReceipt({
receipt,
visibleReplySent: false,
}),
).toEqual({
receipt,
visibleReplySent: false,
});
});
});

View File

@@ -0,0 +1,21 @@
import { listMessageReceiptPlatformIds } from "../message/receipt.js";
import type { MessageReceipt } from "../message/types.js";
import type { ChannelDeliveryIntent, ChannelDeliveryResult } from "./types.js";
export function createChannelDeliveryResultFromReceipt(params: {
receipt: MessageReceipt;
threadId?: string;
replyToId?: string;
visibleReplySent?: boolean;
deliveryIntent?: ChannelDeliveryIntent;
}): ChannelDeliveryResult {
const messageIds = listMessageReceiptPlatformIds(params.receipt);
return {
...(messageIds.length > 0 ? { messageIds } : {}),
receipt: params.receipt,
...(params.threadId ? { threadId: params.threadId } : {}),
...(params.replyToId ? { replyToId: params.replyToId } : {}),
...(params.visibleReplySent === undefined ? {} : { visibleReplySent: params.visibleReplySent }),
...(params.deliveryIntent ? { deliveryIntent: params.deliveryIntent } : {}),
};
}

View File

@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolveOutboundDurableFinalDeliverySupport: vi.fn(),
sendDurableMessageBatch: vi.fn(),
}));
vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/outbound/deliver.js")>();
return {
...actual,
resolveOutboundDurableFinalDeliverySupport: mocks.resolveOutboundDurableFinalDeliverySupport,
};
});
vi.mock("../message/send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../message/send.js")>();
return {
...actual,
sendDurableMessageBatch: mocks.sendDurableMessageBatch,
};
});
import {
deliverInboundReplyWithMessageSendContext,
resolveDurableInboundReplyToId,
} from "./durable-delivery.js";
describe("durable inbound reply delivery", () => {
beforeEach(() => {
mocks.resolveOutboundDurableFinalDeliverySupport.mockReset();
mocks.sendDurableMessageBatch.mockReset();
mocks.resolveOutboundDurableFinalDeliverySupport.mockResolvedValue({ ok: true });
mocks.sendDurableMessageBatch.mockResolvedValue({
status: "sent",
receipt: {
primaryPlatformMessageId: "m1",
platformMessageIds: ["m1"],
parts: [{ platformMessageId: "m1", kind: "text", index: 0 }],
sentAt: 1,
},
});
});
it("preserves explicit null reply targets instead of falling back to context ids", () => {
expect(
resolveDurableInboundReplyToId({
replyToId: null,
payload: { text: "plain reply" },
ctxPayload: {
CommandAuthorized: true,
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
).toBeNull();
});
it("falls back to payload and context reply targets when no explicit null is provided", () => {
expect(
resolveDurableInboundReplyToId({
payload: { text: "payload reply", replyToId: "payload-reply" },
ctxPayload: {
CommandAuthorized: true,
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
).toBe("payload-reply");
expect(
resolveDurableInboundReplyToId({
payload: { text: "context reply" },
ctxPayload: {
CommandAuthorized: true,
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
).toBe("context-full-reply");
});
it("preserves explicit null thread targets instead of falling back to context thread", async () => {
await deliverInboundReplyWithMessageSendContext({
cfg: {},
channel: "telegram",
agentId: "main",
info: { kind: "final" },
payload: { text: "plain reply" },
threadId: null,
ctxPayload: {
CommandAuthorized: true,
OriginatingTo: "chat-1",
MessageThreadId: "context-thread",
},
});
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
channel: "telegram",
to: "chat-1",
threadId: null,
durability: "best_effort",
}),
);
});
it("does not require unknown-send reconciliation for the default best-effort final path", async () => {
await deliverInboundReplyWithMessageSendContext({
cfg: {},
channel: "telegram",
agentId: "main",
info: { kind: "final" },
payload: { text: "final" },
ctxPayload: {
CommandAuthorized: true,
OriginatingTo: "chat-1",
},
});
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
expect.objectContaining({
requirements: {
text: true,
messageSendingHooks: true,
},
}),
);
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
expect.objectContaining({
durability: "best_effort",
}),
);
});
it("uses required durability when a caller explicitly requires unknown-send reconciliation", async () => {
await deliverInboundReplyWithMessageSendContext({
cfg: {},
channel: "telegram",
agentId: "main",
info: { kind: "final" },
payload: { text: "final" },
requiredCapabilities: {
text: true,
reconcileUnknownSend: true,
},
ctxPayload: {
CommandAuthorized: true,
OriginatingTo: "chat-1",
},
});
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
expect.objectContaining({
requirements: {
text: true,
reconcileUnknownSend: true,
},
}),
);
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
expect.objectContaining({
durability: "required",
}),
);
});
});

View File

@@ -0,0 +1,209 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeDeliverableOutboundChannel } from "../../infra/outbound/channel-resolution.js";
import {
type DeliverOutboundPayloadsParams,
type DurableFinalDeliveryRequirement,
type DurableFinalDeliveryRequirements,
type OutboundDeliveryIntent,
resolveOutboundDurableFinalDeliverySupport,
} from "../../infra/outbound/deliver.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { deriveDurableFinalDeliveryRequirements } from "../message/capabilities.js";
import { sendDurableMessageBatch } from "../message/send.js";
import { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
import type { ChannelDeliveryInfo, ChannelDeliveryResult } from "./types.js";
export type DurableInboundReplyDeliveryOptions = Pick<
DeliverOutboundPayloadsParams,
"deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId"
> & {
to?: string | null;
replyToId?: string | null;
requiredCapabilities?: DurableFinalDeliveryRequirements;
};
export type DurableInboundReplyDeliveryParams = DurableInboundReplyDeliveryOptions & {
cfg: OpenClawConfig;
channel: string;
accountId?: string;
agentId: string;
ctxPayload: FinalizedMsgContext;
payload: ReplyPayload;
info: ChannelDeliveryInfo;
};
export type DurableInboundReplyDeliveryResult =
| { status: "not_applicable"; reason: "non_final" }
| {
status: "unsupported";
reason:
| "missing_channel"
| "missing_target"
| "missing_outbound_handler"
| "capability_mismatch";
capability?: DurableFinalDeliveryRequirement;
}
| { status: "handled_visible"; delivery: ChannelDeliveryResult }
| { status: "handled_no_send"; reason: "no_visible_result"; delivery: ChannelDeliveryResult }
| { status: "failed"; error: unknown };
function resolveDeliveryTarget(params: DurableInboundReplyDeliveryParams): string | undefined {
return (
normalizeOptionalString(params.to) ??
normalizeOptionalString(params.ctxPayload.OriginatingTo) ??
normalizeOptionalString(params.ctxPayload.To)
);
}
export function resolveDurableInboundReplyToId(
params: Pick<DurableInboundReplyDeliveryParams, "ctxPayload" | "payload" | "replyToId">,
): string | null | undefined {
if (params.replyToId === null || params.payload.replyToId === null) {
return null;
}
return (
normalizeOptionalString(params.replyToId) ??
normalizeOptionalString(params.payload.replyToId) ??
normalizeOptionalString(params.ctxPayload.ReplyToIdFull) ??
normalizeOptionalString(params.ctxPayload.ReplyToId)
);
}
function resolveDurableInboundReplyThreadId(
params: DurableInboundReplyDeliveryParams,
): string | number | null | undefined {
if ("threadId" in params) {
return params.threadId;
}
return params.ctxPayload.MessageThreadId;
}
function stringifyThreadId(value: string | number | null | undefined): string | undefined {
return value == null ? undefined : String(value);
}
function toDeliveryIntent(intent: OutboundDeliveryIntent): ChannelDeliveryResult["deliveryIntent"] {
return {
id: intent.id,
kind: "outbound_queue",
queuePolicy: intent.queuePolicy,
};
}
export function isDurableInboundReplyDeliveryHandled(
result: DurableInboundReplyDeliveryResult,
): result is Extract<
DurableInboundReplyDeliveryResult,
{ status: "handled_visible" | "handled_no_send" }
> {
return result.status === "handled_visible" || result.status === "handled_no_send";
}
export function throwIfDurableInboundReplyDeliveryFailed(
result: DurableInboundReplyDeliveryResult,
): void {
if (result.status === "failed") {
throw result.error;
}
}
export async function deliverInboundReplyWithMessageSendContext(
params: DurableInboundReplyDeliveryParams,
): Promise<DurableInboundReplyDeliveryResult> {
if (params.info.kind !== "final") {
return { status: "not_applicable", reason: "non_final" };
}
const channel = normalizeDeliverableOutboundChannel(params.channel);
const to = resolveDeliveryTarget(params);
if (!channel) {
return { status: "unsupported", reason: "missing_channel" };
}
if (!to) {
return { status: "unsupported", reason: "missing_target" };
}
const replyToId = resolveDurableInboundReplyToId(params);
const threadId = resolveDurableInboundReplyThreadId(params);
const requiredCapabilities =
params.requiredCapabilities ??
deriveDurableFinalDeliveryRequirements({
payload: params.payload,
replyToId,
threadId,
silent: params.silent,
});
const durability =
requiredCapabilities.reconcileUnknownSend === true ? "required" : "best_effort";
let support: Awaited<ReturnType<typeof resolveOutboundDurableFinalDeliverySupport>>;
try {
support = await resolveOutboundDurableFinalDeliverySupport({
cfg: params.cfg,
channel,
requirements: requiredCapabilities,
});
} catch (err: unknown) {
return { status: "failed", error: err };
}
if (!support.ok) {
return {
status: "unsupported",
reason: support.reason,
...(support.capability ? { capability: support.capability } : {}),
};
}
const session = buildOutboundSessionContext({
cfg: params.cfg,
sessionKey: params.ctxPayload.SessionKey,
policySessionKey: params.ctxPayload.RuntimePolicySessionKey,
conversationType: params.ctxPayload.ChatType,
agentId: params.agentId,
requesterAccountId: params.accountId ?? params.ctxPayload.AccountId,
requesterSenderId: params.ctxPayload.SenderId ?? params.ctxPayload.From,
requesterSenderName: params.ctxPayload.SenderName,
requesterSenderUsername: params.ctxPayload.SenderUsername,
requesterSenderE164: params.ctxPayload.SenderE164,
});
const send = await sendDurableMessageBatch({
cfg: params.cfg,
channel,
to,
accountId: params.accountId,
payloads: [params.payload],
threadId,
replyToId,
replyToMode: params.replyToMode,
formatting: params.formatting,
identity: params.identity,
deps: params.deps,
mediaAccess: params.mediaAccess,
silent: params.silent,
durability,
session,
gatewayClientScopes: params.ctxPayload.GatewayClientScopes,
});
if (send.status === "failed") {
return { status: "failed" as const, error: send.error };
}
const delivery = createChannelDeliveryResultFromReceipt({
receipt: send.receipt,
threadId: stringifyThreadId(threadId),
...(replyToId ? { replyToId } : {}),
visibleReplySent: send.status === "sent",
...(send.deliveryIntent ? { deliveryIntent: toDeliveryIntent(send.deliveryIntent) } : {}),
});
if (send.status === "suppressed") {
return { status: "handled_no_send", reason: "no_visible_result", delivery };
}
return { status: "handled_visible", delivery };
}
/** @deprecated Use `deliverInboundReplyWithMessageSendContext`. */
export const deliverDurableInboundReplyPayload = deliverInboundReplyWithMessageSendContext;

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -13,6 +14,18 @@ import {
runChannelTurn,
} from "./kernel.js";
const deliverOutboundPayloads = vi.hoisted(() => vi.fn());
const resolveOutboundDurableFinalDeliverySupport = vi.hoisted(() => vi.fn());
vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/outbound/deliver.js")>();
return {
...actual,
deliverOutboundPayloads,
resolveOutboundDurableFinalDeliverySupport,
};
});
const cfg = {} as OpenClawConfig;
function createCtx(overrides: Partial<FinalizedMsgContext> = {}): FinalizedMsgContext {
@@ -50,6 +63,357 @@ function createDispatch(
}
describe("channel turn kernel", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveOutboundDurableFinalDeliverySupport.mockResolvedValue({ ok: true });
});
it("routes assembled final replies through durable outbound delivery", async () => {
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tg-1" }]);
const deliver = vi.fn();
const recordInboundSession = createRecordInboundSession();
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
const result = await dispatchAssembledChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({
To: "123",
OriginatingTo: "123",
MessageThreadId: 777,
AccountId: "acct",
ChatType: "group",
SenderId: "sender-1",
}),
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver, durable: { replyToMode: "first" } },
});
expect(result.dispatched).toBe(true);
expect(deliver).not.toHaveBeenCalled();
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123",
accountId: "acct",
payloads: [expect.objectContaining({ text: "reply" })],
queuePolicy: "best_effort",
replyToMode: "first",
threadId: 777,
session: expect.objectContaining({
key: "agent:main:test:peer",
agentId: "main",
requesterAccountId: "acct",
requesterSenderId: "sender-1",
conversationType: "group",
}),
}),
);
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
requirements: {
text: true,
thread: true,
messageSendingHooks: true,
},
}),
);
});
it("returns durable delivery result to the buffered dispatcher", async () => {
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tg-1" }, { messageId: "tg-2" }]);
let deliveredResult: unknown;
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
deliveredResult = await params.dispatcherOptions.deliver(
{ text: "reply" },
{ kind: "final" },
);
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
) as DispatchReplyWithBufferedBlockDispatcher;
await dispatchAssembledChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver: vi.fn(), durable: { replyToMode: "first" } },
});
expect(deliveredResult).toEqual(
expect.objectContaining({
messageIds: ["tg-1", "tg-2"],
receipt: expect.objectContaining({
platformMessageIds: ["tg-1", "tg-2"],
}),
visibleReplySent: true,
}),
);
});
it("prepares payloads before durable enqueue and observes handled delivery", async () => {
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tlon-1" }]);
const onDelivered = vi.fn();
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
await dispatchAssembledChannelTurn({
cfg,
channel: "tlon",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:tlon:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ To: "chat/~nec/general", OriginatingTo: "chat/~nec/general" }),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: vi.fn(),
durable: (payload) => ({
replyToMode: "first",
requiredCapabilities: { text: payload.text?.includes("Generated") === true },
}),
preparePayload: (payload) => ({
...payload,
text: `${payload.text}\n\n_[Generated by test]_`,
}),
onDelivered,
},
});
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
payloads: [expect.objectContaining({ text: "reply\n\n_[Generated by test]_" })],
}),
);
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
expect.objectContaining({
requirements: {
text: true,
},
}),
);
expect(onDelivered).toHaveBeenCalledWith(
expect.objectContaining({ text: "reply\n\n_[Generated by test]_" }),
{ kind: "final" },
expect.objectContaining({ visibleReplySent: true }),
);
});
it("falls back before queueing when durable outbound delivery is unsupported", async () => {
resolveOutboundDurableFinalDeliverySupport.mockResolvedValueOnce({
ok: false,
reason: "missing_outbound_handler",
});
const deliver = vi.fn(async () => ({ messageIds: ["legacy-1"], visibleReplySent: true }));
let deliveredResult: unknown;
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
deliveredResult = await params.dispatcherOptions.deliver(
{ text: "reply" },
{ kind: "final" },
);
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
) as DispatchReplyWithBufferedBlockDispatcher;
await dispatchAssembledChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver, durable: { replyToMode: "first" } },
});
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
requirements: {
text: true,
messageSendingHooks: true,
},
}),
);
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" });
expect(deliveredResult).toEqual(
expect.objectContaining({
messageIds: ["legacy-1"],
visibleReplySent: true,
}),
);
});
it("treats durable outbound support preflight failures as terminal", async () => {
resolveOutboundDurableFinalDeliverySupport.mockRejectedValueOnce(new Error("preflight failed"));
const deliver = vi.fn(async () => ({ messageIds: ["legacy-1"], visibleReplySent: true }));
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
await expect(
dispatchAssembledChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver, durable: { replyToMode: "first" } },
}),
).rejects.toThrow("preflight failed");
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
expect(deliver).not.toHaveBeenCalled();
});
it("returns custom delivery result to the buffered dispatcher", async () => {
let deliveredResult: unknown;
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
deliveredResult = await params.dispatcherOptions.deliver(
{ text: "reply" },
{ kind: "final" },
);
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
) as DispatchReplyWithBufferedBlockDispatcher;
await dispatchAssembledChannelTurn({
cfg,
channel: "test",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: false,
deliver: vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true })),
},
});
expect(deliveredResult).toEqual(
expect.objectContaining({
messageIds: ["local-1"],
visibleReplySent: true,
}),
);
});
it("does not use durable outbound delivery when durable options are omitted", async () => {
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
await dispatchAssembledChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver },
});
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" });
});
it("prepares payloads and observes legacy delivery results", async () => {
const onDelivered = vi.fn();
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
await dispatchAssembledChannelTurn({
cfg,
channel: "test",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver,
preparePayload: (payload) => ({ ...payload, text: `${payload.text}!` }),
onDelivered,
},
});
expect(deliver).toHaveBeenCalledWith({ text: "reply!" }, { kind: "final" });
expect(onDelivered).toHaveBeenCalledWith(
{ text: "reply!" },
{ kind: "final" },
expect.objectContaining({ messageIds: ["local-1"], visibleReplySent: true }),
);
});
it("assembles channel message reply pipeline options inside the turn kernel", async () => {
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
const transformReplyPayload = vi.fn((payload: ReplyPayload) => ({
...payload,
text: `${payload.text} from pipeline`,
}));
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
const transformed = params.dispatcherOptions.transformReplyPayload?.({ text: "reply" });
await params.dispatcherOptions.deliver(transformed ?? { text: "missing" }, {
kind: "final",
});
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
) as DispatchReplyWithBufferedBlockDispatcher;
await dispatchAssembledChannelTurn({
cfg,
channel: "test",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver },
replyPipeline: { transformReplyPayload },
});
expect(transformReplyPayload).toHaveBeenCalledWith({ text: "reply" });
expect(deliver).toHaveBeenCalledWith({ text: "reply from pipeline" }, { kind: "final" });
});
it("records inbound session before dispatching delivery", async () => {
const events: string[] = [];
const deliver = vi.fn(async () => {

View File

@@ -1,8 +1,26 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { clearHistoryEntriesIfEnabled } from "../../auto-reply/reply/history.js";
import { createChannelReplyPipeline } from "../message/reply-pipeline.js";
import type { CreateChannelReplyPipelineParams } from "../message/reply-pipeline.js";
import { EMPTY_CHANNEL_TURN_DISPATCH_COUNTS } from "./dispatch-result.js";
import {
deliverInboundReplyWithMessageSendContext,
isDurableInboundReplyDeliveryHandled,
throwIfDurableInboundReplyDeliveryFailed,
} from "./durable-delivery.js";
export { buildChannelTurnContext, filterChannelTurnSupplementalContext } from "./context.js";
export type { BuildChannelTurnContextParams } from "./context.js";
export {
deliverDurableInboundReplyPayload,
deliverInboundReplyWithMessageSendContext,
isDurableInboundReplyDeliveryHandled,
throwIfDurableInboundReplyDeliveryFailed,
} from "./durable-delivery.js";
export type {
DurableInboundReplyDeliveryOptions,
DurableInboundReplyDeliveryParams,
DurableInboundReplyDeliveryResult,
} from "./durable-delivery.js";
import type {
AssembledChannelTurn,
ChannelEventClass,
@@ -10,6 +28,7 @@ import type {
ChannelTurnDeliveryAdapter,
ChannelTurnHistoryFinalizeOptions,
ChannelTurnLogEvent,
ChannelTurnReplyPipelineOptions,
ChannelTurnResolved,
ChannelTurnResult,
DispatchedChannelTurnResult,
@@ -18,6 +37,7 @@ import type {
RunChannelTurnParams,
RunResolvedChannelTurnParams,
} from "./types.js";
export { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
export {
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
hasFinalChannelTurnDispatch,
@@ -39,6 +59,7 @@ export type {
ChannelTurnDispatcherOptions,
ChannelTurnLogEvent,
ChannelTurnRecordOptions,
ChannelTurnReplyPipelineOptions,
ChannelTurnResolved,
ChannelTurnResult,
DispatchedChannelTurnResult,
@@ -61,6 +82,18 @@ const DEFAULT_EVENT_CLASS: ChannelEventClass = {
canStartAgentTurn: true,
};
/**
* @deprecated Compatibility assembly for legacy buffered reply dispatchers.
* New channel plugins should expose `defineChannelMessageAdapter(...)` from
* `openclaw/plugin-sdk/channel-message` and route send/receive behavior through
* the message lifecycle helpers.
*/
export function createChannelTurnReplyPipeline(
params: CreateChannelReplyPipelineParams,
): ReturnType<typeof createChannelReplyPipeline> {
return createChannelReplyPipeline(params);
}
function isAdmission(value: unknown): value is ChannelTurnAdmission {
if (!value || typeof value !== "object") {
return false;
@@ -113,6 +146,34 @@ function clearPendingHistoryAfterTurn(params?: ChannelTurnHistoryFinalizeOptions
});
}
function resolveAssembledReplyPipeline(
params: AssembledChannelTurn,
): Pick<AssembledChannelTurn, "dispatcherOptions" | "replyOptions"> {
if (!params.replyPipeline) {
return {
dispatcherOptions: params.dispatcherOptions,
replyOptions: params.replyOptions,
};
}
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
...params.replyPipeline,
});
return {
dispatcherOptions: {
...replyPipeline,
...params.dispatcherOptions,
},
replyOptions: {
onModelSelected,
...params.replyOptions,
},
};
}
function resolveObserveOnlyDispatchResult<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
): TDispatchResult {
@@ -125,6 +186,7 @@ function resolveObserveOnlyDispatchResult<TDispatchResult>(
export async function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<DispatchedChannelTurnResult> {
const replyPipeline = resolveAssembledReplyPipeline(params);
return await runPreparedChannelTurnCore(
{
channel: params.channel,
@@ -143,13 +205,39 @@ export async function dispatchAssembledChannelTurn(
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...params.dispatcherOptions,
...replyPipeline.dispatcherOptions,
deliver: async (payload: ReplyPayload, info) => {
await params.delivery.deliver(payload, info);
const preparedPayload = params.delivery.preparePayload
? await params.delivery.preparePayload(payload, info)
: payload;
const durableOptions =
typeof params.delivery.durable === "function"
? await params.delivery.durable(preparedPayload, info)
: params.delivery.durable;
if (durableOptions) {
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
agentId: params.agentId,
ctxPayload: params.ctxPayload,
payload: preparedPayload,
info,
...durableOptions,
});
throwIfDurableInboundReplyDeliveryFailed(durable);
if (isDurableInboundReplyDeliveryHandled(durable)) {
await params.delivery.onDelivered?.(preparedPayload, info, durable.delivery);
return durable.delivery;
}
}
const result = await params.delivery.deliver(preparedPayload, info);
await params.delivery.onDelivered?.(preparedPayload, info, result);
return result;
},
onError: params.delivery.onError,
},
replyOptions: params.replyOptions,
replyOptions: replyPipeline.replyOptions,
replyResolver: params.replyResolver,
}),
},

View File

@@ -9,6 +9,13 @@ import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.
import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
import type { GroupKeyResolution } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type {
DeliverOutboundPayloadsParams,
DurableFinalDeliveryRequirements,
OutboundDeliveryQueuePolicy,
} from "../../infra/outbound/deliver.js";
import type { CreateChannelReplyPipelineParams } from "../message/reply-pipeline.js";
import type { MessageReceipt } from "../message/types.js";
import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.types.js";
export type ChannelTurnAdmission =
@@ -168,18 +175,54 @@ export type ChannelDeliveryInfo = {
kind: ReplyDispatchKind;
};
export type ChannelDeliveryIntent = {
id: string;
kind: "outbound_queue";
queuePolicy: OutboundDeliveryQueuePolicy;
};
export type ChannelDeliveryResult = {
messageIds?: string[];
receipt?: MessageReceipt;
threadId?: string;
replyToId?: string;
visibleReplySent?: boolean;
deliveryIntent?: ChannelDeliveryIntent;
};
export type ChannelTurnDurableDeliveryOptions = Pick<
DeliverOutboundPayloadsParams,
"deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId"
> & {
to?: string | null;
replyToId?: string | null;
requiredCapabilities?: DurableFinalDeliveryRequirements;
};
export type ChannelTurnDeliveryAdapter = {
preparePayload?: (
payload: ReplyPayload,
info: ChannelDeliveryInfo,
) => Promise<ReplyPayload> | ReplyPayload;
deliver: (
payload: ReplyPayload,
info: ChannelDeliveryInfo,
) => Promise<ChannelDeliveryResult | void>;
durable?:
| false
| ChannelTurnDurableDeliveryOptions
| ((
payload: ReplyPayload,
info: ChannelDeliveryInfo,
) =>
| false
| ChannelTurnDurableDeliveryOptions
| Promise<false | ChannelTurnDurableDeliveryOptions>);
onDelivered?: (
payload: ReplyPayload,
info: ChannelDeliveryInfo,
result: ChannelDeliveryResult | void,
) => Promise<void> | void;
onError?: (err: unknown, info: { kind: string }) => void;
};
@@ -203,6 +246,11 @@ export type ChannelTurnDispatcherOptions = Omit<
"deliver" | "onError"
>;
export type ChannelTurnReplyPipelineOptions = Omit<
CreateChannelReplyPipelineParams,
"cfg" | "agentId" | "channel" | "accountId"
>;
export type AssembledChannelTurn = {
cfg: OpenClawConfig;
channel: string;
@@ -214,6 +262,7 @@ export type AssembledChannelTurn = {
recordInboundSession: RecordInboundSession;
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
delivery: ChannelTurnDeliveryAdapter;
replyPipeline?: ChannelTurnReplyPipelineOptions;
dispatcherOptions?: ChannelTurnDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;

View File

@@ -169,16 +169,12 @@ describe("loadChannelConfigSurfaceModule", () => {
await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => {
const { modulePath } = createDemoConfigSchemaModule(repoRoot, ["export const = ;"]);
const {
loadChannelConfigSurfaceModule: loadWithFailingJiti,
spawnSync,
createJiti,
} = await importLoaderWithFailingJitiAndWorkingBun();
const { loadChannelConfigSurfaceModule: loadWithFailingJiti, spawnSync } =
await importLoaderWithFailingJitiAndWorkingBun();
await expect(loadWithFailingJiti(modulePath, { repoRoot })).resolves.toMatchObject(
expectedOkSchema("number"),
);
expect(createJiti).toHaveBeenCalled();
expect(spawnSync).toHaveBeenCalledWith("bun", expect.any(Array), expect.any(Object));
});
});

View File

@@ -277,6 +277,8 @@ export type SessionEntry = {
pendingFinalDeliveryText?: string | null;
/** Original delivery context (channel, recipient, etc). */
pendingFinalDeliveryContext?: DeliveryContext;
/** Durable send intent backing pending final delivery, when already created. */
pendingFinalDeliveryIntentId?: string | null;
/**
* Whether totalTokens reflects a fresh context snapshot for the latest run.
* Undefined means legacy/unknown freshness; false forces consumers to treat

View File

@@ -12,6 +12,7 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh
type FormatChannelSelectionLine =
typeof import("../channels/registry.js").formatChannelSelectionLine;
type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured;
type ChannelSetupStatusModule = typeof import("./channel-setup.status.js");
type NoteChannelPrimerChannels = Parameters<
typeof import("./channel-setup.status.js").noteChannelPrimer
>[1];
@@ -73,15 +74,14 @@ vi.mock("../plugins/bundled-sources.js", () => ({
findBundledPluginSourceInMap: () => undefined,
}));
import {
collectChannelStatus,
noteChannelPrimer,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
} from "./channel-setup.status.js";
let collectChannelStatus: ChannelSetupStatusModule["collectChannelStatus"];
let noteChannelPrimer: ChannelSetupStatusModule["noteChannelPrimer"];
let resolveChannelSelectionNoteLines: ChannelSetupStatusModule["resolveChannelSelectionNoteLines"];
let resolveChannelSetupSelectionContributions: ChannelSetupStatusModule["resolveChannelSetupSelectionContributions"];
describe("resolveChannelSetupSelectionContributions", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
listChatChannels.mockReturnValue([
makeMeta("discord", "Discord"),
@@ -93,6 +93,12 @@ describe("resolveChannelSetupSelectionContributions", () => {
);
formatChannelSelectionLine.mockImplementation((meta) => `${meta.label}${meta.blurb}`);
isChannelConfigured.mockReturnValue(false);
({
collectChannelStatus,
noteChannelPrimer,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
} = await import("./channel-setup.status.js"));
});
it("sorts channels alphabetically by picker label", () => {

View File

@@ -109,7 +109,7 @@ export type {
ChannelMessageActionName,
} from "../channels/plugins/types.public.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
export { createChannelReplyPipeline } from "./channel-reply-core.js";
export type { OpenClawConfig } from "../config/config.js";
export type { DmPolicy, GroupPolicy } from "../config/types.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";

View File

@@ -323,7 +323,6 @@ describe("loadBundledEntryExportSync", () => {
fs.writeFileSync(openedFdPath, "opened\n", "utf8");
const jitiLoad = vi.fn(() => ({ load: 42 }));
const createJiti = vi.fn(() => jitiLoad);
stubPluginModuleLoaderJitiFactory(createJiti as unknown as PluginModuleLoaderFactory);
vi.doMock("../infra/boundary-file-read.js", () => ({
openBoundaryFileSync: () => ({
ok: true,
@@ -345,6 +344,7 @@ describe("loadBundledEntryExportSync", () => {
specifier: "./helper.ts",
exportName: "load",
},
{ createLoaderForTest: createJiti as never },
),
).toBe(42);
expect(jitiLoad).toHaveBeenCalledWith(

View File

@@ -15,6 +15,7 @@ import {
} from "../plugins/plugin-load-profile.js";
import {
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderFactory,
type PluginModuleLoaderCache,
} from "../plugins/plugin-module-loader-cache.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
@@ -132,7 +133,9 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
features?: BundledChannelSetupEntryFeatures;
};
export type BundledEntryModuleLoadOptions = Record<string, never>;
export type BundledEntryModuleLoadOptions = {
createLoaderForTest?: PluginModuleLoaderFactory;
};
const nodeRequire = createRequire(import.meta.url);
const moduleLoaders: PluginModuleLoaderCache = new Map();
@@ -329,13 +332,14 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string)
);
}
function getSourceModuleLoader(modulePath: string) {
function getSourceModuleLoader(modulePath: string, options: BundledEntryModuleLoadOptions) {
return getCachedPluginSourceModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
loaderFilename: import.meta.url,
...(options.createLoaderForTest ? { createLoader: options.createLoaderForTest } : {}),
});
}
@@ -352,7 +356,7 @@ function canTryNodeRequireBuiltModule(modulePath: string): boolean {
function loadBundledEntryModuleSync(
importMetaUrl: string,
specifier: string,
_options: BundledEntryModuleLoadOptions = {},
options: BundledEntryModuleLoadOptions = {},
): unknown {
const modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier);
const cached = loadedModuleExports.get(modulePath);
@@ -367,12 +371,12 @@ function loadBundledEntryModuleSync(
try {
loaded = nodeRequire(modulePath);
} catch {
const moduleLoader = getSourceModuleLoader(modulePath);
const moduleLoader = getSourceModuleLoader(modulePath, options);
sourceLoaderReadyMs = profile ? performance.now() : 0;
loaded = moduleLoader(toSafeImportPath(modulePath));
}
} else {
const moduleLoader = getSourceModuleLoader(modulePath);
const moduleLoader = getSourceModuleLoader(modulePath, options);
sourceLoaderReadyMs = profile ? performance.now() : 0;
loaded = moduleLoader(toSafeImportPath(modulePath));
}

View File

@@ -0,0 +1,28 @@
export {
buildChannelMessageReplyDispatchBase,
dispatchChannelMessageReplyWithBase,
hasFinalChannelMessageReplyDispatch,
hasVisibleChannelMessageReplyDispatch,
recordChannelMessageReplyDispatch,
resolveChannelMessageReplyDispatchCounts,
} from "./inbound-reply-dispatch.js";
export {
createChannelTurnReplyPipeline,
deliverDurableInboundReplyPayload,
deliverInboundReplyWithMessageSendContext,
} from "../channels/turn/kernel.js";
export type {
DurableInboundReplyDeliveryOptions,
DurableInboundReplyDeliveryParams,
DurableInboundReplyDeliveryResult,
} from "../channels/turn/kernel.js";
export {
sendDurableMessageBatch,
withDurableMessageSendContext,
} from "../channels/message/runtime.js";
export type {
DurableMessageBatchSendParams,
DurableMessageBatchSendResult,
DurableMessageSendContext,
DurableMessageSendContextParams,
} from "../channels/message/runtime.js";

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { defineChannelMessageAdapter } from "./channel-message.js";
describe("defineChannelMessageAdapter", () => {
it("keeps new and legacy channel plugin SDK subpaths importable", async () => {
const [channelMessage, channelMessageRuntime, channelReplyPipeline, compat] = await Promise.all(
[
import("openclaw/plugin-sdk/channel-message"),
import("openclaw/plugin-sdk/channel-message-runtime"),
import("openclaw/plugin-sdk/channel-reply-pipeline"),
import("openclaw/plugin-sdk/compat"),
],
);
expect(channelMessage.createChannelMessageReplyPipeline).toBe(
channelReplyPipeline.createChannelReplyPipeline,
);
expect(channelMessage.createReplyPrefixOptions).toBe(
channelReplyPipeline.createReplyPrefixOptions,
);
expect(channelMessage.createTypingCallbacks).toBe(channelReplyPipeline.createTypingCallbacks);
expect(typeof channelMessageRuntime.sendDurableMessageBatch).toBe("function");
expect(typeof compat.createChannelReplyPipeline).toBe("function");
});
it("defaults new message adapters to plugin-owned receive acknowledgement", () => {
const adapter = defineChannelMessageAdapter({
id: "demo",
durableFinal: { capabilities: { text: true } },
send: {
text: vi.fn(async () => ({
receipt: {
primaryPlatformMessageId: "msg-1",
platformMessageIds: ["msg-1"],
parts: [],
sentAt: 123,
},
})),
},
});
expect(adapter.receive).toEqual({
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
});
});
it("preserves explicit receive acknowledgement policy declarations", () => {
const adapter = defineChannelMessageAdapter({
id: "demo",
receive: {
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
},
});
expect(adapter.receive).toEqual({
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
});
});
});

View File

@@ -0,0 +1,221 @@
import type {
ChannelMessageAdapter,
ChannelMessageAdapterShape,
} from "../channels/message/index.js";
import type { ChannelMessageReceiveAdapterShape } from "../channels/message/index.js";
import type {
DurableMessageBatchSendParams,
DurableMessageBatchSendResult,
DurableMessageSendContext,
DurableMessageSendContextParams,
} from "../channels/message/runtime.js";
import {
hasFinalChannelTurnDispatch,
hasVisibleChannelTurnDispatch,
resolveChannelTurnDispatchCounts,
} from "../channels/turn/dispatch-result.js";
import {
createChannelReplyPipeline,
type CreateChannelReplyPipelineParams,
} from "./channel-reply-core.js";
export type {
DurableInboundReplyDeliveryOptions,
DurableInboundReplyDeliveryParams,
DurableInboundReplyDeliveryResult,
} from "../channels/turn/kernel.js";
export type {
DurableMessageBatchSendParams,
DurableMessageBatchSendResult,
DurableMessageSendContext,
DurableMessageSendContextParams,
} from "../channels/message/runtime.js";
export {
createChannelReplyPipeline as createChannelMessageReplyPipeline,
createReplyPrefixContext,
createReplyPrefixOptions,
createTypingCallbacks,
resolveChannelSourceReplyDeliveryMode as resolveChannelMessageSourceReplyDeliveryMode,
} from "./channel-reply-core.js";
export {
classifyDurableSendRecoveryState,
createChannelMessageAdapterFromOutbound,
createMessageReceiptFromOutboundResults,
listMessageReceiptPlatformIds,
createMessageReceiveContext,
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deriveDurableFinalDeliveryRequirements,
deliverFinalizableLivePreview,
deliverWithFinalizableLivePreviewAdapter,
listDeclaredChannelMessageLiveCapabilities,
listDeclaredDurableFinalCapabilities,
listDeclaredLivePreviewFinalizerCapabilities,
listDeclaredReceiveAckPolicies,
createLiveMessageState,
createDurableMessageStateRecord,
markLiveMessageCancelled,
markLiveMessageFinalized,
markLiveMessagePreviewUpdated,
resolveMessageReceiptPrimaryId,
shouldAckMessageAfterStage,
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveCapabilityProofs,
verifyChannelMessageLiveFinalizerProofs,
verifyChannelMessageReceiveAckPolicyAdapterProofs,
verifyChannelMessageReceiveAckPolicyProofs,
verifyDurableFinalCapabilityProofs,
verifyLivePreviewFinalizerCapabilityProofs,
} from "../channels/message/index.js";
export type {
ChannelMessageAdapter,
ChannelMessageAdapterShape,
ChannelMessageDurableFinalAdapter,
ChannelMessageLiveFinalizerAdapterShape,
ChannelMessageLiveAdapterShape,
ChannelMessageLiveCapability,
ChannelMessageOutboundBridgeAdapter,
ChannelMessageOutboundBridgeResult,
ChannelMessageReceiveAckPolicy,
ChannelMessageReceiveAdapterShape,
ChannelMessageSendAdapter,
ChannelMessageSendAttemptContext,
ChannelMessageSendAttemptKind,
ChannelMessageSendCommitContext,
ChannelMessageSendFailureContext,
ChannelMessageSendLifecycleAdapter,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendResult,
ChannelMessageSendSuccessContext,
ChannelMessageSendTextContext,
ChannelMessageUnknownSendContext,
ChannelMessageUnknownSendReconciliationResult,
CreateChannelReplyPipelineParams,
CreateChannelMessageAdapterFromOutboundParams,
DeriveDurableFinalDeliveryRequirementsParams,
ChannelMessageLiveCapabilityProof,
ChannelMessageLiveCapabilityProofMap,
ChannelMessageLiveCapabilityProofResult,
ChannelMessageReceiveAckPolicyProof,
ChannelMessageReceiveAckPolicyProofMap,
ChannelMessageReceiveAckPolicyProofResult,
DurableFinalCapabilityProof,
DurableFinalCapabilityProofMap,
DurableFinalCapabilityProofResult,
DurableFinalDeliveryCapability,
DurableFinalDeliveryPayloadShape,
DurableFinalDeliveryRequirementMap,
DurableFinalRequirementExtras,
DurableMessageSendIntent,
DurableMessageSendState,
DurableMessageStateRecord,
FinalizableLivePreviewAdapter,
LiveMessagePhase,
LiveMessageState,
LivePreviewFinalizerCapability,
LivePreviewFinalizerCapabilityMap,
LivePreviewFinalizerDraft,
LivePreviewFinalizerCapabilityProof,
LivePreviewFinalizerCapabilityProofMap,
LivePreviewFinalizerCapabilityProofResult,
LivePreviewFinalizerResult,
LivePreviewFinalizerResultKind,
MessageAckPolicy,
MessageAckStage,
MessageAckState,
MessageReceiveContext,
MessageSendContext,
MessageDurabilityPolicy,
MessageReceipt,
MessageReceiptPart,
MessageReceiptPartKind,
MessageReceiptSourceResult,
RenderedMessageBatch,
RenderedMessageBatchPlan,
RenderedMessageBatchPlanItem,
RenderedMessageBatchPlanKind,
} from "../channels/message/index.js";
type ChannelTurnKernelModule = typeof import("../channels/turn/kernel.js");
type InboundReplyDispatchModule = typeof import("./inbound-reply-dispatch.js");
export function createChannelTurnReplyPipeline(params: CreateChannelReplyPipelineParams) {
return createChannelReplyPipeline(params);
}
export const hasFinalChannelMessageReplyDispatch = hasFinalChannelTurnDispatch;
export const hasVisibleChannelMessageReplyDispatch = hasVisibleChannelTurnDispatch;
export const resolveChannelMessageReplyDispatchCounts = resolveChannelTurnDispatchCounts;
export const buildChannelMessageReplyDispatchBase: InboundReplyDispatchModule["buildChannelMessageReplyDispatchBase"] =
((params) => ({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
agentId: params.route.agentId,
routeSessionKey: params.route.sessionKey,
storePath: params.storePath,
ctxPayload: params.ctxPayload,
recordInboundSession: params.core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
})) as InboundReplyDispatchModule["buildChannelMessageReplyDispatchBase"];
export const dispatchChannelMessageReplyWithBase: InboundReplyDispatchModule["dispatchChannelMessageReplyWithBase"] =
async (...args) => {
const mod = await import("./inbound-reply-dispatch.js");
return await mod.dispatchChannelMessageReplyWithBase(...args);
};
export const recordChannelMessageReplyDispatch: InboundReplyDispatchModule["recordChannelMessageReplyDispatch"] =
async (...args) => {
const mod = await import("./inbound-reply-dispatch.js");
return await mod.recordChannelMessageReplyDispatch(...args);
};
export const deliverInboundReplyWithMessageSendContext: ChannelTurnKernelModule["deliverInboundReplyWithMessageSendContext"] =
async (...args) => {
const mod = await import("../channels/turn/kernel.js");
return await mod.deliverInboundReplyWithMessageSendContext(...args);
};
/** @deprecated Use `deliverInboundReplyWithMessageSendContext`. */
export const deliverDurableInboundReplyPayload = deliverInboundReplyWithMessageSendContext;
export async function sendDurableMessageBatch(
params: DurableMessageBatchSendParams,
): Promise<DurableMessageBatchSendResult> {
const mod = await import("../channels/message/runtime.js");
return await mod.sendDurableMessageBatch(params);
}
export async function withDurableMessageSendContext<T>(
params: DurableMessageSendContextParams,
run: (ctx: DurableMessageSendContext) => Promise<T>,
): Promise<T> {
const mod = await import("../channels/message/runtime.js");
return await mod.withDurableMessageSendContext(params, run);
}
const defaultManualReceiveAdapter = {
defaultAckPolicy: "manual",
supportedAckPolicies: ["manual"],
} as const satisfies ChannelMessageReceiveAdapterShape;
type ChannelMessageAdapterWithDefaultReceive<TAdapter extends ChannelMessageAdapterShape> =
TAdapter & {
receive: TAdapter["receive"] extends undefined
? typeof defaultManualReceiveAdapter
: NonNullable<TAdapter["receive"]>;
};
export function defineChannelMessageAdapter<const TAdapter extends ChannelMessageAdapterShape>(
adapter: TAdapter,
): ChannelMessageAdapter<ChannelMessageAdapterWithDefaultReceive<TAdapter>> {
return {
...adapter,
receive: adapter.receive ?? defaultManualReceiveAdapter,
} as ChannelMessageAdapter<ChannelMessageAdapterWithDefaultReceive<TAdapter>>;
}

View File

@@ -0,0 +1,17 @@
export {
createChannelReplyPipeline,
createReplyPrefixContext,
createReplyPrefixOptions,
createTypingCallbacks,
resolveChannelSourceReplyDeliveryMode,
} from "../channels/message/reply-pipeline.js";
export type {
ChannelReplyPipeline,
CreateChannelReplyPipelineParams,
CreateTypingCallbacksParams,
ReplyPrefixContext,
ReplyPrefixContextBundle,
ReplyPrefixOptions,
SourceReplyDeliveryMode,
TypingCallbacks,
} from "../channels/message/reply-pipeline.js";

View File

@@ -1,87 +1,21 @@
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import {
resolveSourceReplyDeliveryMode,
type SourceReplyDeliveryModeContext,
} from "../auto-reply/reply/source-reply-delivery-mode.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import {
/**
* @deprecated Legacy reply-pipeline subpath. New channel message code should
* use `openclaw/plugin-sdk/channel-message`.
*/
export {
createChannelReplyPipeline,
createReplyPrefixContext,
createReplyPrefixOptions,
type ReplyPrefixContextBundle,
type ReplyPrefixOptions,
} from "../channels/reply-prefix.js";
import {
createTypingCallbacks,
type CreateTypingCallbacksParams,
type TypingCallbacks,
} from "../channels/typing.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ReplyPayload } from "./reply-payload.js";
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
export type { CreateTypingCallbacksParams, TypingCallbacks };
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
export type { SourceReplyDeliveryMode };
export function resolveChannelSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
}): SourceReplyDeliveryMode {
return resolveSourceReplyDeliveryMode(params);
}
export type ChannelReplyPipeline = ReplyPrefixOptions & {
typingCallbacks?: TypingCallbacks;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
};
export function createChannelReplyPipeline(params: {
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
agentId: string;
channel?: string;
accountId?: string;
typing?: CreateTypingCallbacksParams;
typingCallbacks?: TypingCallbacks;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
}): ChannelReplyPipeline {
const channelId = params.channel
? (normalizeChannelId(params.channel) ?? params.channel)
: undefined;
let plugin: ReturnType<typeof getChannelPlugin> | undefined;
let pluginTransformResolved = false;
const resolvePluginTransform = () => {
if (pluginTransformResolved) {
return plugin?.messaging?.transformReplyPayload;
}
pluginTransformResolved = true;
plugin = channelId ? getChannelPlugin(channelId) : undefined;
return plugin?.messaging?.transformReplyPayload;
};
const transformReplyPayload = params.transformReplyPayload
? params.transformReplyPayload
: channelId
? (payload: ReplyPayload) =>
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
: undefined;
return {
...createReplyPrefixOptions({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
}),
...(transformReplyPayload ? { transformReplyPayload } : {}),
...(params.typingCallbacks
? { typingCallbacks: params.typingCallbacks }
: params.typing
? { typingCallbacks: createTypingCallbacks(params.typing) }
: {}),
};
}
resolveChannelSourceReplyDeliveryMode,
} from "./channel-reply-core.js";
export type {
ChannelReplyPipeline,
CreateTypingCallbacksParams,
ReplyPrefixContext,
ReplyPrefixContextBundle,
ReplyPrefixOptions,
SourceReplyDeliveryMode,
TypingCallbacks,
} from "./channel-reply-core.js";

View File

@@ -17,7 +17,7 @@ import {
type ReplyPrefixOptions as ReplyPrefixOptionsCompat,
type SourceReplyDeliveryMode as SourceReplyDeliveryModeCompat,
type TypingCallbacks as TypingCallbacksCompat,
} from "./channel-reply-pipeline.js";
} from "./channel-reply-core.js";
const shouldWarnCompatImport =
process.env.VITEST !== "true" &&
@@ -84,29 +84,29 @@ export * from "./reply-history.js";
export * from "./directory-runtime.js";
export { mapAllowlistResolutionInputs } from "./allow-from.js";
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export const createChannelReplyPipeline = createChannelReplyPipelineCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export const createReplyPrefixContext = createReplyPrefixContextCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export const createReplyPrefixOptions = createReplyPrefixOptionsCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export const createTypingCallbacks = createTypingCallbacksCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export const resolveChannelSourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryModeCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type ChannelReplyPipeline = ChannelReplyPipelineCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type CreateTypingCallbacksParams = CreateTypingCallbacksParamsCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type ReplyPrefixContext = ReplyPrefixContextCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type ReplyPrefixContextBundle = ReplyPrefixContextBundleCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type ReplyPrefixOptions = ReplyPrefixOptionsCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type SourceReplyDeliveryMode = SourceReplyDeliveryModeCompat;
/** @deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`. */
/** @deprecated Use `openclaw/plugin-sdk/channel-message`. */
export type TypingCallbacks = TypingCallbacksCompat;
export {

View File

@@ -1,9 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js";
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import type { RecordInboundSession } from "../channels/session.types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
const deliverInboundReplyWithMessageSendContext = vi.hoisted(() => vi.fn());
vi.mock("../channels/turn/kernel.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/turn/kernel.js")>();
return {
...actual,
deliverInboundReplyWithMessageSendContext,
};
});
import {
createChannelMessageReplyPipeline,
createReplyPrefixOptions as createChannelMessageReplyPrefixOptions,
createReplyPrefixContext as createChannelMessageReplyPrefixContext,
createTypingCallbacks as createChannelMessageTypingCallbacks,
dispatchChannelMessageReplyWithBase,
hasFinalChannelMessageReplyDispatch,
recordChannelMessageReplyDispatch,
resolveChannelMessageSourceReplyDeliveryMode,
} from "./channel-message.js";
import {
createChannelReplyPipeline,
createReplyPrefixContext,
createReplyPrefixOptions,
createTypingCallbacks,
resolveChannelSourceReplyDeliveryMode,
} from "./channel-reply-pipeline.js";
import {
dispatchInboundReplyWithBase,
hasFinalInboundReplyDispatch,
hasVisibleInboundReplyDispatch,
recordInboundSessionAndDispatchReply,
@@ -11,6 +40,10 @@ import {
} from "./inbound-reply-dispatch.js";
describe("recordInboundSessionAndDispatchReply", () => {
beforeEach(() => {
deliverInboundReplyWithMessageSendContext.mockReset();
});
it("delegates record and dispatch through the channel turn kernel once", async () => {
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
const deliver = vi.fn(async () => undefined);
@@ -38,7 +71,7 @@ describe("recordInboundSessionAndDispatchReply", () => {
Surface: "test",
} as FinalizedMsgContext;
await recordInboundSessionAndDispatchReply({
await recordChannelMessageReplyDispatch({
cfg: {} as OpenClawConfig,
channel: "test",
accountId: "default",
@@ -70,6 +103,116 @@ describe("recordInboundSessionAndDispatchReply", () => {
});
});
it("keeps public compatibility delivery channel-owned when durable is omitted", async () => {
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
const deliver = vi.fn(async () => undefined);
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
await params.dispatcherOptions.deliver({ text: "hello" }, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
}) as DispatchReplyWithBufferedBlockDispatcher;
await recordInboundSessionAndDispatchReply({
cfg: {} as OpenClawConfig,
channel: "telegram",
accountId: "default",
agentId: "main",
routeSessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
ctxPayload: {
Body: "body",
RawBody: "body",
CommandBody: "body",
From: "sender",
To: "123",
OriginatingTo: "123",
SessionKey: "agent:main:telegram:peer",
Provider: "telegram",
Surface: "telegram",
} as FinalizedMsgContext,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
deliver,
onRecordError: vi.fn(),
onDispatchError: vi.fn(),
});
expect(deliver).toHaveBeenCalledWith({
text: "hello",
mediaUrl: undefined,
mediaUrls: undefined,
sensitiveMedia: undefined,
replyToId: undefined,
});
});
it("forwards durable delivery options through the SDK convenience wrapper", async () => {
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
status: "handled_visible",
delivery: {
messageIds: ["queued-1"],
visibleReplySent: true,
},
});
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
const deliver = vi.fn(async () => undefined);
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
await params.dispatcherOptions.deliver({ text: "hello durable" }, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
}) as DispatchReplyWithBufferedBlockDispatcher;
const ctxPayload = {
Body: "body",
RawBody: "body",
CommandBody: "body",
From: "sender",
To: "123",
OriginatingTo: "123",
SessionKey: "agent:main:telegram:peer",
Provider: "telegram",
Surface: "telegram",
} as FinalizedMsgContext;
await dispatchChannelMessageReplyWithBase({
cfg: {} as OpenClawConfig,
channel: "telegram",
accountId: "default",
route: {
agentId: "main",
sessionKey: "agent:main:telegram:peer",
},
storePath: "/tmp/sessions.json",
ctxPayload,
core: {
channel: {
session: { recordInboundSession },
reply: { dispatchReplyWithBufferedBlockDispatcher },
},
},
deliver,
durable: { replyToMode: "first" },
onRecordError: vi.fn(),
onDispatchError: vi.fn(),
});
expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
accountId: "default",
agentId: "main",
ctxPayload,
payload: expect.objectContaining({ text: "hello durable" }),
info: { kind: "final" },
replyToMode: "first",
}),
);
expect(deliver).not.toHaveBeenCalled();
});
it("exports shared visible reply dispatch helpers", () => {
expect(hasVisibleInboundReplyDispatch(undefined)).toBe(false);
expect(
@@ -95,4 +238,19 @@ describe("recordInboundSessionAndDispatchReply", () => {
final: 0,
});
});
it("exposes channel-message dispatch names as the canonical helpers for new channel code", () => {
expect(createChannelMessageReplyPipeline).toBe(createChannelReplyPipeline);
expect(resolveChannelMessageSourceReplyDeliveryMode).toBe(
resolveChannelSourceReplyDeliveryMode,
);
expect(createChannelMessageReplyPrefixContext).toBe(createReplyPrefixContext);
expect(createChannelMessageReplyPrefixOptions).toBe(createReplyPrefixOptions);
expect(createChannelMessageTypingCallbacks).toBe(createTypingCallbacks);
expect(typeof dispatchChannelMessageReplyWithBase).toBe("function");
expect(typeof dispatchInboundReplyWithBase).toBe("function");
expect(hasFinalChannelMessageReplyDispatch).toBe(hasFinalInboundReplyDispatch);
expect(typeof recordChannelMessageReplyDispatch).toBe("function");
expect(typeof recordInboundSessionAndDispatchReply).toBe("function");
});
});

View File

@@ -10,15 +10,24 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import {
hasFinalChannelTurnDispatch,
hasVisibleChannelTurnDispatch,
deliverInboundReplyWithMessageSendContext,
isDurableInboundReplyDeliveryHandled,
resolveChannelTurnDispatchCounts,
runChannelTurn,
runPreparedChannelTurn,
throwIfDurableInboundReplyDeliveryFailed,
} from "../channels/turn/kernel.js";
import type { DurableInboundReplyDeliveryOptions } from "../channels/turn/kernel.js";
import type { PreparedChannelTurn, RunChannelTurnParams } from "../channels/turn/types.js";
export type { ChannelTurnRecordOptions } from "../channels/turn/types.js";
export type { DurableInboundReplyDeliveryParams } from "../channels/turn/kernel.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
import { createChannelReplyPipeline } from "./channel-reply-core.js";
import {
normalizeOutboundReplyPayload,
type OutboundReplyPayload,
type ReplyPayload,
} from "./reply-payload.js";
type ReplyOptionsWithoutModelSelected = Omit<
Omit<GetReplyOptions, "onBlockReply">,
@@ -45,6 +54,8 @@ export async function runInboundReplyTurn<TRaw, TDispatchResult = DispatchFromCo
export {
hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch,
hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch,
deliverInboundReplyWithMessageSendContext as deliverDurableInboundReplyPayload,
deliverInboundReplyWithMessageSendContext,
resolveChannelTurnDispatchCounts as resolveInboundReplyDispatchCounts,
};
@@ -108,30 +119,7 @@ export function buildInboundReplyDispatchBase(params: {
}
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
type RecordInboundSessionAndDispatchReplyParams = Parameters<
typeof recordInboundSessionAndDispatchReply
>[0];
/** Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn. */
export async function dispatchInboundReplyWithBase(
params: BuildInboundReplyDispatchBaseParams &
Pick<
RecordInboundSessionAndDispatchReplyParams,
"deliver" | "onRecordError" | "onDispatchError" | "replyOptions"
>,
): Promise<void> {
const dispatchBase = buildInboundReplyDispatchBase(params);
await recordInboundSessionAndDispatchReply({
...dispatchBase,
deliver: params.deliver,
onRecordError: params.onRecordError,
onDispatchError: params.onDispatchError,
replyOptions: params.replyOptions,
});
}
/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */
export async function recordInboundSessionAndDispatchReply(params: {
type RecordChannelMessageReplyDispatchParams = {
cfg: OpenClawConfig;
channel: string;
accountId?: string;
@@ -142,17 +130,80 @@ export async function recordInboundSessionAndDispatchReply(params: {
recordInboundSession: RecordInboundSessionFn;
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
deliver: (payload: OutboundReplyPayload) => Promise<void>;
durable?: false | DurableInboundReplyDeliveryOptions;
onRecordError: (err: unknown) => void;
onDispatchError: (err: unknown, info: { kind: string }) => void;
replyOptions?: ReplyOptionsWithoutModelSelected;
}): Promise<void> {
};
/**
* Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn.
*/
export async function dispatchChannelMessageReplyWithBase(
params: BuildInboundReplyDispatchBaseParams &
Pick<
RecordChannelMessageReplyDispatchParams,
"deliver" | "durable" | "onRecordError" | "onDispatchError" | "replyOptions"
>,
): Promise<void> {
const dispatchBase = buildInboundReplyDispatchBase(params);
await recordChannelMessageReplyDispatch({
...dispatchBase,
deliver: params.deliver,
durable: params.durable,
onRecordError: params.onRecordError,
onDispatchError: params.onDispatchError,
replyOptions: params.replyOptions,
});
}
/**
* Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn.
*
* @deprecated Legacy inbound reply helper. New channel plugins should expose a
* `message` adapter via `defineChannelMessageAdapter(...)` and use
* `dispatchChannelMessageReplyWithBase` only for compatibility dispatchers that
* have not moved to the message lifecycle yet.
*/
export async function dispatchInboundReplyWithBase(
params: Parameters<typeof dispatchChannelMessageReplyWithBase>[0],
): Promise<void> {
await dispatchChannelMessageReplyWithBase(params);
}
/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */
export async function recordChannelMessageReplyDispatch(
params: RecordChannelMessageReplyDispatchParams,
): Promise<void> {
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
});
const deliver = createNormalizedOutboundDeliverer(params.deliver);
const deliver = async (payload: unknown, info: { kind: "tool" | "block" | "final" }) => {
const normalized =
payload && typeof payload === "object"
? normalizeOutboundReplyPayload(payload as Record<string, unknown>)
: {};
if (params.durable) {
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
agentId: params.agentId,
ctxPayload: params.ctxPayload,
payload: normalized as ReplyPayload,
info,
...params.durable,
});
throwIfDurableInboundReplyDeliveryFailed(durable);
if (isDurableInboundReplyDeliveryHandled(durable)) {
return;
}
}
await params.deliver(normalized);
};
await runPreparedChannelTurn({
channel: params.channel,
@@ -180,3 +231,22 @@ export async function recordInboundSessionAndDispatchReply(params: {
}),
});
}
/**
* Record the inbound session first, then dispatch the reply using normalized outbound delivery.
*
* @deprecated Legacy inbound reply helper. New channel plugins should expose a
* `message` adapter via `defineChannelMessageAdapter(...)` and use
* `recordChannelMessageReplyDispatch` only for compatibility dispatchers that
* have not moved to the message lifecycle yet.
*/
export async function recordInboundSessionAndDispatchReply(
params: RecordChannelMessageReplyDispatchParams,
): Promise<void> {
await recordChannelMessageReplyDispatch(params);
}
export const buildChannelMessageReplyDispatchBase = buildInboundReplyDispatchBase;
export const hasFinalChannelMessageReplyDispatch = hasFinalChannelTurnDispatch;
export const hasVisibleChannelMessageReplyDispatch = hasVisibleChannelTurnDispatch;
export const resolveChannelMessageReplyDispatchCounts = resolveChannelTurnDispatchCounts;

View File

@@ -1,5 +1,11 @@
import type { ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js";
export type DeliveryIntentRef = {
id: string;
kind: "outbound_queue";
queuePolicy?: "required" | "best_effort";
};
export type DeliveryContext = Pick<
ChannelRouteTargetInput,
"accountId" | "channel" | "threadId" | "to"
@@ -8,6 +14,7 @@ export type DeliveryContext = Pick<
to?: string;
accountId?: string;
threadId?: string | number;
deliveryIntent?: DeliveryIntentRef;
};
export type DeliveryContextSessionSource = {