diff --git a/package.json b/package.json index f8a986fc443..fc8ef3bc54a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f8113513fa6..a8f0cd0868e 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -176,6 +176,8 @@ "channel-location", "channel-mention-gating", "channel-lifecycle", + "channel-message", + "channel-message-runtime", "channel-pairing", "channel-pairing-paths", "channel-policy", diff --git a/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts b/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts index c67ecd6783b..1bf4ee41155 100644 --- a/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts @@ -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", diff --git a/src/channels/draft-preview-finalizer.ts b/src/channels/draft-preview-finalizer.ts index bfd2d65cf58..0f3148f91cf 100644 --- a/src/channels/draft-preview-finalizer.ts +++ b/src/channels/draft-preview-finalizer.ts @@ -1,16 +1,25 @@ -export type DraftPreviewFinalizerDraft = { - flush: () => Promise; - id: () => TId | undefined; - seal?: () => Promise; - discardPending?: () => Promise; - clear: () => Promise; -}; +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 = LivePreviewFinalizerDraft; +/** + * @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(params: { kind: "tool" | "block" | "final"; payload: TPayload; @@ -22,49 +31,21 @@ export async function deliverFinalizableDraftPreview(param onNormalDelivered?: () => Promise | void; logPreviewEditFailure?: (error: unknown) => void; }): Promise { - 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; } diff --git a/src/channels/message/capabilities.test.ts b/src/channels/message/capabilities.test.ts new file mode 100644 index 00000000000..a817780815a --- /dev/null +++ b/src/channels/message/capabilities.test.ts @@ -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, + }); + }); +}); diff --git a/src/channels/message/capabilities.ts b/src/channels/message/capabilities.ts new file mode 100644 index 00000000000..c40c454f997 --- /dev/null +++ b/src/channels/message/capabilities.ts @@ -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; +} diff --git a/src/channels/message/contracts.test.ts b/src/channels/message/contracts.test.ts new file mode 100644 index 00000000000..d59beb70efa --- /dev/null +++ b/src/channels/message/contracts.test.ts @@ -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'); + }); +}); diff --git a/src/channels/message/contracts.ts b/src/channels/message/contracts.ts new file mode 100644 index 00000000000..fae39cd940e --- /dev/null +++ b/src/channels/message/contracts.ts @@ -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; + +export type DurableFinalCapabilityProofMap = Partial< + Record +>; + +export type DurableFinalCapabilityProofResult = { + capability: DurableFinalDeliveryCapability; + status: "verified" | "not_declared"; +}; + +export type LivePreviewFinalizerCapabilityProof = () => Promise | void; + +export type ChannelMessageLiveCapabilityProof = () => Promise | void; + +export type ChannelMessageReceiveAckPolicyProof = () => Promise | void; + +export type LivePreviewFinalizerCapabilityProofMap = Partial< + Record +>; + +export type ChannelMessageLiveCapabilityProofMap = Partial< + Record +>; + +export type ChannelMessageReceiveAckPolicyProofMap = Partial< + Record +>; + +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> | 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 { + 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 { + 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>; + proofs: ChannelMessageLiveCapabilityProofMap; +}): Promise { + 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 { + 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; + proofs: DurableFinalCapabilityProofMap; +}): Promise { + return await verifyDurableFinalCapabilityProofs({ + adapterName: params.adapterName, + capabilities: params.adapter.durableFinal?.capabilities, + proofs: params.proofs, + }); +} + +export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: { + adapterName: string; + adapter: Pick; + proofs: ChannelMessageReceiveAckPolicyProofMap; +}): Promise { + return await verifyChannelMessageReceiveAckPolicyProofs({ + adapterName: params.adapterName, + receive: params.adapter.receive, + proofs: params.proofs, + }); +} + +export async function verifyChannelMessageLiveFinalizerProofs(params: { + adapterName: string; + adapter: Pick; + proofs: LivePreviewFinalizerCapabilityProofMap; +}): Promise { + return await verifyLivePreviewFinalizerCapabilityProofs({ + adapterName: params.adapterName, + capabilities: params.adapter.live?.finalizer?.capabilities, + proofs: params.proofs, + }); +} + +export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: { + adapterName: string; + adapter: Pick; + proofs: ChannelMessageLiveCapabilityProofMap; +}): Promise { + return await verifyChannelMessageLiveCapabilityProofs({ + adapterName: params.adapterName, + capabilities: params.adapter.live?.capabilities, + proofs: params.proofs, + }); +} diff --git a/src/channels/message/index.ts b/src/channels/message/index.ts new file mode 100644 index 00000000000..7144a2ee3ab --- /dev/null +++ b/src/channels/message/index.ts @@ -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"; diff --git a/src/channels/message/lifecycle.test.ts b/src/channels/message/lifecycle.test.ts new file mode 100644 index 00000000000..faeaa9e5d72 --- /dev/null +++ b/src/channels/message/lifecycle.test.ts @@ -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, + }), + ); + }); +}); diff --git a/src/channels/message/live.ts b/src/channels/message/live.ts new file mode 100644 index 00000000000..f05da00cb54 --- /dev/null +++ b/src/channels/message/live.ts @@ -0,0 +1,273 @@ +import type { LiveMessageState, MessageReceipt, RenderedMessageBatch } from "./types.js"; +export type { LiveMessagePhase, LiveMessageState } from "./types.js"; + +export type LivePreviewFinalizerDraft = { + flush: () => Promise; + id: () => TId | undefined; + seal?: () => Promise; + discardPending?: () => Promise; + clear: () => Promise; +}; + +export type LivePreviewFinalizerResultKind = + | "normal-delivered" + | "normal-skipped" + | "preview-finalized" + | "preview-retained"; + +export type LivePreviewFinalizerResult = { + kind: LivePreviewFinalizerResultKind; + liveState?: LiveMessageState; +}; + +export type FinalizableLivePreviewAdapter = { + draft?: LivePreviewFinalizerDraft; + buildFinalEdit: (payload: TPayload) => TEdit | undefined; + editFinal: (id: TId, edit: TEdit) => Promise; + resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined; + createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt; + onPreviewFinalized?: ( + id: TId, + receipt: MessageReceipt, + liveState: LiveMessageState, + ) => Promise | void; + handlePreviewEditError?: (params: { + error: unknown; + id: TId; + edit: TEdit; + payload: TPayload; + liveState: LiveMessageState; + }) => "fallback" | "retain" | Promise<"fallback" | "retain">; + logPreviewEditFailure?: (error: unknown) => void; +}; + +export function defineFinalizableLivePreviewAdapter( + adapter: FinalizableLivePreviewAdapter, +): FinalizableLivePreviewAdapter { + return adapter; +} + +export function createLiveMessageState(params?: { + receipt?: MessageReceipt; + lastRendered?: RenderedMessageBatch; + canFinalizeInPlace?: boolean; +}): LiveMessageState { + 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( + state: LiveMessageState, + receipt: MessageReceipt, +): LiveMessageState { + 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(params: { + kind: "tool" | "block" | "final"; + payload: TPayload; + liveState?: LiveMessageState; + draft?: LivePreviewFinalizerDraft; + buildFinalEdit: (payload: TPayload) => TEdit | undefined; + editFinal: (id: TId, edit: TEdit) => Promise; + resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined; + deliverNormally: (payload: TPayload) => Promise; + createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt; + onPreviewFinalized?: ( + id: TId, + receipt: MessageReceipt, + liveState: LiveMessageState, + ) => Promise | void; + handlePreviewEditError?: (params: { + error: unknown; + id: TId; + edit: TEdit; + payload: TPayload; + liveState: LiveMessageState; + }) => "fallback" | "retain" | Promise<"fallback" | "retain">; + onNormalDelivered?: () => Promise | void; + logPreviewEditFailure?: (error: unknown) => void; +}): Promise> { + let liveState = + params.liveState ?? + createLiveMessageState({ 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(params: { + kind: "tool" | "block" | "final"; + payload: TPayload; + liveState?: LiveMessageState; + adapter?: FinalizableLivePreviewAdapter; + deliverNormally: (payload: TPayload) => Promise; + onNormalDelivered?: () => Promise | void; +}): Promise> { + if (!params.adapter) { + const liveState = params.liveState ?? createLiveMessageState(); + 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( + state: LiveMessageState, + rendered: RenderedMessageBatch, +): LiveMessageState { + return { + ...state, + phase: "previewing", + lastRendered: rendered, + }; +} + +export function markLiveMessageCancelled( + state: LiveMessageState, +): LiveMessageState { + return { + ...state, + phase: "cancelled", + canFinalizeInPlace: false, + }; +} diff --git a/src/channels/message/outbound-bridge.test.ts b/src/channels/message/outbound-bridge.test.ts new file mode 100644 index 00000000000..41fab3324e8 --- /dev/null +++ b/src/channels/message/outbound-bridge.test.ts @@ -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"], + }); + }); +}); diff --git a/src/channels/message/outbound-bridge.ts b/src/channels/message/outbound-bridge.ts new file mode 100644 index 00000000000..658524646d0 --- /dev/null +++ b/src/channels/message/outbound-bridge.ts @@ -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 = { + deliveryCapabilities?: { + durableFinal?: DurableFinalDeliveryRequirementMap; + }; + sendText?: ( + ctx: ChannelMessageSendTextContext, + ) => Promise; + sendMedia?: ( + ctx: ChannelMessageSendMediaContext, + ) => Promise; + sendPayload?: ( + ctx: ChannelMessageSendPayloadContext, + ) => Promise; +}; + +export type CreateChannelMessageAdapterFromOutboundParams = { + id?: string; + outbound: ChannelMessageOutboundBridgeAdapter; + 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, +): 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( + params: CreateChannelMessageAdapterFromOutboundParams, +): ChannelMessageAdapterShape { + const send: NonNullable["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), + 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, + }; +} diff --git a/src/channels/message/receipt.test.ts b/src/channels/message/receipt.test.ts new file mode 100644 index 00000000000..d4d12798cb8 --- /dev/null +++ b/src/channels/message/receipt.test.ts @@ -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"); + }); +}); diff --git a/src/channels/message/receipt.ts b/src/channels/message/receipt.ts new file mode 100644 index 00000000000..c76d2b82016 --- /dev/null +++ b/src/channels/message/receipt.ts @@ -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]; +} diff --git a/src/channels/message/receive.ts b/src/channels/message/receive.ts new file mode 100644 index 00000000000..2bf4849bce2 --- /dev/null +++ b/src/channels/message/receive.ts @@ -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 = { + 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; + nack(error: unknown): Promise; +}; + +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(params: { + id: string; + channel: string; + accountId?: string; + message: TMessage; + ackPolicy?: MessageAckPolicy; + receivedAt?: number; + signal?: AbortSignal; + onAck?: () => Promise | void; + onNack?: (error: unknown) => Promise | void; +}): MessageReceiveContext { + const ctx: MessageReceiveContext = { + 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; +} diff --git a/src/channels/message/rendered-batch.ts b/src/channels/message/rendered-batch.ts new file mode 100644 index 00000000000..18416077b9c --- /dev/null +++ b/src/channels/message/rendered-batch.ts @@ -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( + (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 { + return { + payloads, + plan: createRenderedMessageBatchPlan(payloads), + }; +} diff --git a/src/channels/message/reply-pipeline.ts b/src/channels/message/reply-pipeline.ts new file mode 100644 index 00000000000..c0f9c561f65 --- /dev/null +++ b/src/channels/message/reply-pipeline.ts @@ -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[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 | 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) } + : {}), + }; +} diff --git a/src/channels/message/runtime.ts b/src/channels/message/runtime.ts new file mode 100644 index 00000000000..1032d9043f7 --- /dev/null +++ b/src/channels/message/runtime.ts @@ -0,0 +1,7 @@ +export { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js"; +export type { + DurableMessageBatchSendParams, + DurableMessageBatchSendResult, + DurableMessageSendContext, + DurableMessageSendContextParams, +} from "./send.js"; diff --git a/src/channels/message/send.test.ts b/src/channels/message/send.test.ts new file mode 100644 index 00000000000..3e753be158d --- /dev/null +++ b/src/channels/message/send.test.ts @@ -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); + }); +}); diff --git a/src/channels/message/send.ts b/src/channels/message/send.ts new file mode 100644 index 00000000000..052d56b1f46 --- /dev/null +++ b/src/channels/message/send.ts @@ -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, +): DurableMessageSendIntent { + 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; + preview?: LiveMessageState; + onPreviewUpdate?: ( + rendered: RenderedMessageBatch, + state: LiveMessageState, + ) => Promise> | LiveMessageState; + onEditReceipt?: ( + receipt: MessageReceipt, + rendered: RenderedMessageBatch, + ) => Promise | MessageReceipt; + onDeleteReceipt?: (receipt: MessageReceipt) => Promise | void; + onCommitReceipt?: (receipt: MessageReceipt) => Promise | void; + onSendFailure?: (error: unknown) => Promise | void; +}; + +export type DurableMessageSendContext = MessageSendContext< + ReplyPayload, + DurableMessageBatchSendResult +>; + +export async function withDurableMessageSendContext( + params: DurableMessageSendContextParams, + run: (ctx: DurableMessageSendContext) => Promise, +): Promise { + 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(); + 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> => + createRenderedMessageBatch(payloads), + previewUpdate: async (rendered): Promise> => { + liveState = onPreviewUpdate + ? await onPreviewUpdate(rendered, liveState) + : markLiveMessagePreviewUpdated(liveState, rendered); + ctx.preview = liveState; + return liveState; + }, + send: async (rendered): Promise => { + 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 => { + 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 { + 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; + }); +} diff --git a/src/channels/message/state.ts b/src/channels/message/state.ts new file mode 100644 index 00000000000..da247b3ed64 --- /dev/null +++ b/src/channels/message/state.ts @@ -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); +} diff --git a/src/channels/message/types.ts b/src/channels/message/types.ts new file mode 100644 index 00000000000..32df48cc631 --- /dev/null +++ b/src/channels/message/types.ts @@ -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 +>; + +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; +}; + +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 = { + payloads: TPayload[]; + plan: RenderedMessageBatchPlan; +}; + +export type LiveMessagePhase = "idle" | "previewing" | "finalizing" | "finalized" | "cancelled"; + +export type LiveMessageState = { + phase: LiveMessagePhase; + canFinalizeInPlace: boolean; + receipt?: MessageReceipt; + lastRendered?: RenderedMessageBatch; +}; + +export type MessageSendContext = { + id: string; + channel: string; + to: string; + accountId?: string; + durability: Exclude; + attempt: number; + signal: AbortSignal; + intent?: DurableMessageSendIntent; + previousReceipt?: MessageReceipt; + preview?: LiveMessageState; + render(): Promise>; + previewUpdate(rendered: RenderedMessageBatch): Promise>; + send(rendered: RenderedMessageBatch): Promise; + edit(receipt: MessageReceipt, rendered: RenderedMessageBatch): Promise; + delete(receipt: MessageReceipt): Promise; + commit(receipt: MessageReceipt): Promise; + fail(error: unknown): Promise; +}; + +export type ChannelMessageSendTextContext = { + 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 = + ChannelMessageSendTextContext & { + mediaUrl: string; + mediaAccess?: OutboundMediaAccess; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; + audioAsVoice?: boolean; + gifPlayback?: boolean; + forceDocument?: boolean; + }; + +export type ChannelMessageSendPayloadContext = + ChannelMessageSendTextContext & { + payload: ReplyPayload; + mediaUrl?: string; + mediaAccess?: OutboundMediaAccess; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; + audioAsVoice?: boolean; + gifPlayback?: boolean; + forceDocument?: boolean; + }; + +export type ChannelMessageSendResult = { + receipt: MessageReceipt; + messageId?: string; +}; + +export type ChannelMessageSendAttemptKind = "text" | "media" | "payload"; + +export type ChannelMessageSendAttemptContext = + | (ChannelMessageSendTextContext & { kind: "text" }) + | (ChannelMessageSendMediaContext & { kind: "media" }) + | (ChannelMessageSendPayloadContext & { kind: "payload" }); + +export type ChannelMessageSendSuccessContext< + TConfig = OpenClawConfig, + TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, +> = ChannelMessageSendAttemptContext & { + result: TSendResult; + attemptToken?: unknown; +}; + +export type ChannelMessageSendFailureContext = + ChannelMessageSendAttemptContext & { + error: unknown; + attemptToken?: unknown; + }; + +export type ChannelMessageSendCommitContext< + TConfig = OpenClawConfig, + TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, +> = ChannelMessageSendSuccessContext; + +export type ChannelMessageUnknownSendContext = { + 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) => unknown; + afterSendSuccess?: ( + ctx: ChannelMessageSendSuccessContext, + ) => Promise | void; + afterSendFailure?: (ctx: ChannelMessageSendFailureContext) => Promise | void; + afterCommit?: ( + ctx: ChannelMessageSendCommitContext, + ) => Promise | void; +}; + +export type ChannelMessageSendAdapter< + TConfig = OpenClawConfig, + TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, +> = { + text?: (ctx: ChannelMessageSendTextContext) => Promise; + media?: (ctx: ChannelMessageSendMediaContext) => Promise; + payload?: (ctx: ChannelMessageSendPayloadContext) => Promise; + lifecycle?: ChannelMessageSendLifecycleAdapter; +}; + +export type ChannelMessageDurableFinalAdapter = { + capabilities?: DurableFinalDeliveryRequirementMap; + reconcileUnknownSend?: ( + ctx: ChannelMessageUnknownSendContext, + ) => + | Promise + | 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 +>; + +export type ChannelMessageLiveFinalizerAdapterShape = { + capabilities?: LivePreviewFinalizerCapabilityMap; +}; + +export type ChannelMessageLiveAdapterShape = { + capabilities?: Partial>; + 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; + 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 = { + id: string; + channel: string; + to: string; + accountId?: string; + durability: Exclude; + renderedBatch?: RenderedMessageBatch; +}; diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 71a48c637b8..5b682b9ef66 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -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 = diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 477f16a54fc..c69644aa577 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -24,6 +24,7 @@ export type { ChannelOutboundPayloadContext, ChannelOutboundPayloadHint, ChannelOutboundTargetRef, + ChannelDeliveryCapabilities, } from "./outbound.types.js"; import type { ChannelAccountSnapshot, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index daecd345f92..7f3058a6df4 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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 }) => 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; /** * Prefer this for channel-specific poll semantics or extra poll parameters. * Core only parses the shared poll model when falling back to `outbound.sendPoll`. diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 261cbaa87c5..f4c3f6bcf15 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -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 { + 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, + }); + }); +}); diff --git a/src/channels/turn/delivery-result.ts b/src/channels/turn/delivery-result.ts new file mode 100644 index 00000000000..df3a4a27d23 --- /dev/null +++ b/src/channels/turn/delivery-result.ts @@ -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 } : {}), + }; +} diff --git a/src/channels/turn/durable-delivery.test.ts b/src/channels/turn/durable-delivery.test.ts new file mode 100644 index 00000000000..6211dc253ad --- /dev/null +++ b/src/channels/turn/durable-delivery.test.ts @@ -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(); + return { + ...actual, + resolveOutboundDurableFinalDeliverySupport: mocks.resolveOutboundDurableFinalDeliverySupport, + }; +}); + +vi.mock("../message/send.js", async (importOriginal) => { + const actual = await importOriginal(); + 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", + }), + ); + }); +}); diff --git a/src/channels/turn/durable-delivery.ts b/src/channels/turn/durable-delivery.ts new file mode 100644 index 00000000000..b9501b93b17 --- /dev/null +++ b/src/channels/turn/durable-delivery.ts @@ -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, +): 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 { + 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>; + 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; diff --git a/src/channels/turn/kernel.test.ts b/src/channels/turn/kernel.test.ts index be2ffc63e0b..2a9c4049608 100644 --- a/src/channels/turn/kernel.test.ts +++ b/src/channels/turn/kernel.test.ts @@ -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(); + return { + ...actual, + deliverOutboundPayloads, + resolveOutboundDurableFinalDeliverySupport, + }; +}); + const cfg = {} as OpenClawConfig; function createCtx(overrides: Partial = {}): 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[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[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[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[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 () => { diff --git a/src/channels/turn/kernel.ts b/src/channels/turn/kernel.ts index 62f9009e1dd..8ed79a5ae45 100644 --- a/src/channels/turn/kernel.ts +++ b/src/channels/turn/kernel.ts @@ -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 { + 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 { + 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( params: PreparedChannelTurn, ): TDispatchResult { @@ -125,6 +186,7 @@ function resolveObserveOnlyDispatchResult( export async function dispatchAssembledChannelTurn( params: AssembledChannelTurn, ): Promise { + 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, }), }, diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index eff2f6e7a42..3b3135c3a6d 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -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; deliver: ( payload: ReplyPayload, info: ChannelDeliveryInfo, ) => Promise; + durable?: + | false + | ChannelTurnDurableDeliveryOptions + | (( + payload: ReplyPayload, + info: ChannelDeliveryInfo, + ) => + | false + | ChannelTurnDurableDeliveryOptions + | Promise); + onDelivered?: ( + payload: ReplyPayload, + info: ChannelDeliveryInfo, + result: ChannelDeliveryResult | void, + ) => Promise | 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; replyResolver?: GetReplyFromConfig; diff --git a/src/config/load-channel-config-surface.test.ts b/src/config/load-channel-config-surface.test.ts index 58974db53b4..1d68b769748 100644 --- a/src/config/load-channel-config-surface.test.ts +++ b/src/config/load-channel-config-surface.test.ts @@ -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)); }); }); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6e8062a5a86..c257cc40c96 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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 diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 38e9bc40352..d4f450538f0 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -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", () => { diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 3719918c4e6..22b02f47545 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -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"; diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 3521011cb2b..a9c9cf397f0 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -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( diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 4ccd32934d7..fffbde83816 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -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 = { features?: BundledChannelSetupEntryFeatures; }; -export type BundledEntryModuleLoadOptions = Record; +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)); } diff --git a/src/plugin-sdk/channel-message-runtime.ts b/src/plugin-sdk/channel-message-runtime.ts new file mode 100644 index 00000000000..c39854866a9 --- /dev/null +++ b/src/plugin-sdk/channel-message-runtime.ts @@ -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"; diff --git a/src/plugin-sdk/channel-message.test.ts b/src/plugin-sdk/channel-message.test.ts new file mode 100644 index 00000000000..21976d24efb --- /dev/null +++ b/src/plugin-sdk/channel-message.test.ts @@ -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"], + }); + }); +}); diff --git a/src/plugin-sdk/channel-message.ts b/src/plugin-sdk/channel-message.ts new file mode 100644 index 00000000000..b8a7caa3999 --- /dev/null +++ b/src/plugin-sdk/channel-message.ts @@ -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 { + const mod = await import("../channels/message/runtime.js"); + return await mod.sendDurableMessageBatch(params); +} + +export async function withDurableMessageSendContext( + params: DurableMessageSendContextParams, + run: (ctx: DurableMessageSendContext) => Promise, +): Promise { + 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 & { + receive: TAdapter["receive"] extends undefined + ? typeof defaultManualReceiveAdapter + : NonNullable; + }; + +export function defineChannelMessageAdapter( + adapter: TAdapter, +): ChannelMessageAdapter> { + return { + ...adapter, + receive: adapter.receive ?? defaultManualReceiveAdapter, + } as ChannelMessageAdapter>; +} diff --git a/src/plugin-sdk/channel-reply-core.ts b/src/plugin-sdk/channel-reply-core.ts new file mode 100644 index 00000000000..fbb84b28c8d --- /dev/null +++ b/src/plugin-sdk/channel-reply-core.ts @@ -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"; diff --git a/src/plugin-sdk/channel-reply-pipeline.ts b/src/plugin-sdk/channel-reply-pipeline.ts index 6a4ae08c4b5..d5233688c68 100644 --- a/src/plugin-sdk/channel-reply-pipeline.ts +++ b/src/plugin-sdk/channel-reply-pipeline.ts @@ -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[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 | 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"; diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 508fc74b881..2b973fc3c87 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -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 { diff --git a/src/plugin-sdk/inbound-reply-dispatch.test.ts b/src/plugin-sdk/inbound-reply-dispatch.test.ts index d02c94a25b8..ddef225621b 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.test.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.test.ts @@ -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(); + 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"); + }); }); diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts index 28c523797e3..cd17203600f 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -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, @@ -45,6 +54,8 @@ export async function runInboundReplyTurn[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 { - 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; + durable?: false | DurableInboundReplyDeliveryOptions; onRecordError: (err: unknown) => void; onDispatchError: (err: unknown, info: { kind: string }) => void; replyOptions?: ReplyOptionsWithoutModelSelected; -}): Promise { +}; + +/** + * 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 { + 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[0], +): Promise { + await dispatchChannelMessageReplyWithBase(params); +} + +/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */ +export async function recordChannelMessageReplyDispatch( + params: RecordChannelMessageReplyDispatchParams, +): Promise { 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) + : {}; + 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 { + await recordChannelMessageReplyDispatch(params); +} + +export const buildChannelMessageReplyDispatchBase = buildInboundReplyDispatchBase; +export const hasFinalChannelMessageReplyDispatch = hasFinalChannelTurnDispatch; +export const hasVisibleChannelMessageReplyDispatch = hasVisibleChannelTurnDispatch; +export const resolveChannelMessageReplyDispatchCounts = resolveChannelTurnDispatchCounts; diff --git a/src/utils/delivery-context.types.ts b/src/utils/delivery-context.types.ts index 6e0bfb0d76f..15fcc344cfc 100644 --- a/src/utils/delivery-context.types.ts +++ b/src/utils/delivery-context.types.ts @@ -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 = {