mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
feat: add channel message lifecycle sdk
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -176,6 +176,8 @@
|
||||
"channel-location",
|
||||
"channel-mention-gating",
|
||||
"channel-lifecycle",
|
||||
"channel-message",
|
||||
"channel-message-runtime",
|
||||
"channel-pairing",
|
||||
"channel-pairing-paths",
|
||||
"channel-policy",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
export type DraftPreviewFinalizerDraft<TId> = {
|
||||
flush: () => Promise<void>;
|
||||
id: () => TId | undefined;
|
||||
seal?: () => Promise<void>;
|
||||
discardPending?: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
};
|
||||
import {
|
||||
deliverFinalizableLivePreview,
|
||||
type LivePreviewFinalizerDraft,
|
||||
type LivePreviewFinalizerResultKind,
|
||||
} from "./message/live.js";
|
||||
|
||||
export type DraftPreviewFinalizerResult =
|
||||
| "normal-delivered"
|
||||
| "normal-skipped"
|
||||
| "preview-finalized";
|
||||
/**
|
||||
* @deprecated Use `LivePreviewFinalizerDraft` from `openclaw/plugin-sdk/channel-message`.
|
||||
*/
|
||||
export type DraftPreviewFinalizerDraft<TId> = LivePreviewFinalizerDraft<TId>;
|
||||
|
||||
/**
|
||||
* @deprecated Use `LivePreviewFinalizerResult` from `openclaw/plugin-sdk/channel-message`.
|
||||
*/
|
||||
export type DraftPreviewFinalizerResult = Exclude<
|
||||
LivePreviewFinalizerResultKind,
|
||||
"preview-retained"
|
||||
>;
|
||||
|
||||
/**
|
||||
* @deprecated Use `deliverFinalizableLivePreview` from `openclaw/plugin-sdk/channel-message`.
|
||||
*/
|
||||
export async function deliverFinalizableDraftPreview<TPayload, TId, TEdit>(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
payload: TPayload;
|
||||
@@ -22,49 +31,21 @@ export async function deliverFinalizableDraftPreview<TPayload, TId, TEdit>(param
|
||||
onNormalDelivered?: () => Promise<void> | void;
|
||||
logPreviewEditFailure?: (error: unknown) => void;
|
||||
}): Promise<DraftPreviewFinalizerResult> {
|
||||
if (params.kind !== "final" || !params.draft) {
|
||||
const delivered = await params.deliverNormally(params.payload);
|
||||
if (delivered === false) {
|
||||
return "normal-skipped";
|
||||
}
|
||||
await params.onNormalDelivered?.();
|
||||
return "normal-delivered";
|
||||
}
|
||||
const result = await deliverFinalizableLivePreview({
|
||||
kind: params.kind,
|
||||
payload: params.payload,
|
||||
...(params.draft ? { draft: params.draft } : {}),
|
||||
buildFinalEdit: params.buildFinalEdit,
|
||||
editFinal: params.editFinal,
|
||||
deliverNormally: params.deliverNormally,
|
||||
onPreviewFinalized: async (id) => {
|
||||
await params.onPreviewFinalized?.(id);
|
||||
},
|
||||
...(params.onNormalDelivered ? { onNormalDelivered: params.onNormalDelivered } : {}),
|
||||
...(params.logPreviewEditFailure
|
||||
? { logPreviewEditFailure: params.logPreviewEditFailure }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const edit = params.buildFinalEdit(params.payload);
|
||||
if (edit !== undefined) {
|
||||
await params.draft.flush();
|
||||
const previewId = params.draft.id();
|
||||
if (previewId !== undefined) {
|
||||
await params.draft.seal?.();
|
||||
try {
|
||||
await params.editFinal(previewId, edit);
|
||||
await params.onPreviewFinalized?.(previewId);
|
||||
return "preview-finalized";
|
||||
} catch (err) {
|
||||
params.logPreviewEditFailure?.(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.draft.discardPending) {
|
||||
await params.draft.discardPending();
|
||||
} else {
|
||||
await params.draft.clear();
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
try {
|
||||
const result = await params.deliverNormally(params.payload);
|
||||
delivered = result !== false;
|
||||
if (delivered) {
|
||||
await params.onNormalDelivered?.();
|
||||
}
|
||||
} finally {
|
||||
if (delivered) {
|
||||
await params.draft.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return delivered ? "normal-delivered" : "normal-skipped";
|
||||
return result.kind === "preview-retained" ? "normal-skipped" : result.kind;
|
||||
}
|
||||
|
||||
58
src/channels/message/capabilities.test.ts
Normal file
58
src/channels/message/capabilities.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDurableFinalDeliveryRequirements } from "./capabilities.js";
|
||||
|
||||
describe("deriveDurableFinalDeliveryRequirements", () => {
|
||||
it("derives the default durable final text and hook requirements", () => {
|
||||
expect(deriveDurableFinalDeliveryRequirements({ payload: { text: "hello" } })).toEqual({
|
||||
text: true,
|
||||
messageSendingHooks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives payload-dependent delivery requirements", () => {
|
||||
expect(
|
||||
deriveDurableFinalDeliveryRequirements({
|
||||
payload: {
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.png"],
|
||||
replyToId: "reply-1",
|
||||
},
|
||||
threadId: 42,
|
||||
silent: true,
|
||||
payloadTransport: true,
|
||||
batch: true,
|
||||
reconcileUnknownSend: true,
|
||||
afterSendSuccess: true,
|
||||
afterCommit: true,
|
||||
}),
|
||||
).toEqual({
|
||||
text: true,
|
||||
media: true,
|
||||
replyTo: true,
|
||||
thread: true,
|
||||
silent: true,
|
||||
messageSendingHooks: true,
|
||||
payload: true,
|
||||
batch: true,
|
||||
reconcileUnknownSend: true,
|
||||
afterSendSuccess: true,
|
||||
afterCommit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies channel-native extras without recording false requirements", () => {
|
||||
expect(
|
||||
deriveDurableFinalDeliveryRequirements({
|
||||
payload: { text: "hello" },
|
||||
extraCapabilities: {
|
||||
nativeQuote: false,
|
||||
thread: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
text: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
56
src/channels/message/capabilities.ts
Normal file
56
src/channels/message/capabilities.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
DeriveDurableFinalDeliveryRequirementsParams,
|
||||
DurableFinalDeliveryCapability,
|
||||
DurableFinalDeliveryRequirementMap,
|
||||
} from "./types.js";
|
||||
|
||||
function hasMediaPayload(
|
||||
payload: DeriveDurableFinalDeliveryRequirementsParams["payload"],
|
||||
): boolean {
|
||||
if (payload.mediaUrl?.trim()) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(payload.mediaUrls) &&
|
||||
payload.mediaUrls.some((url) => typeof url === "string" && url.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function setRequired(
|
||||
requirements: DurableFinalDeliveryRequirementMap,
|
||||
capability: DurableFinalDeliveryCapability,
|
||||
required: boolean | undefined,
|
||||
): void {
|
||||
if (required === true) {
|
||||
requirements[capability] = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveDurableFinalDeliveryRequirements(
|
||||
params: DeriveDurableFinalDeliveryRequirementsParams,
|
||||
): DurableFinalDeliveryRequirementMap {
|
||||
const requirements: DurableFinalDeliveryRequirementMap = {};
|
||||
setRequired(requirements, "text", true);
|
||||
setRequired(requirements, "media", hasMediaPayload(params.payload));
|
||||
setRequired(
|
||||
requirements,
|
||||
"replyTo",
|
||||
params.replyToId != null || params.payload.replyToId != null,
|
||||
);
|
||||
setRequired(requirements, "thread", params.threadId != null);
|
||||
setRequired(requirements, "silent", params.silent);
|
||||
setRequired(requirements, "messageSendingHooks", params.messageSendingHooks !== false);
|
||||
setRequired(requirements, "payload", params.payloadTransport);
|
||||
setRequired(requirements, "batch", params.batch);
|
||||
setRequired(requirements, "reconcileUnknownSend", params.reconcileUnknownSend);
|
||||
setRequired(requirements, "afterSendSuccess", params.afterSendSuccess);
|
||||
setRequired(requirements, "afterCommit", params.afterCommit);
|
||||
|
||||
for (const [capability, required] of Object.entries(params.extraCapabilities ?? {}) as Array<
|
||||
[DurableFinalDeliveryCapability, boolean | undefined]
|
||||
>) {
|
||||
setRequired(requirements, capability, required);
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
293
src/channels/message/contracts.test.ts
Normal file
293
src/channels/message/contracts.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listDeclaredChannelMessageLiveCapabilities,
|
||||
listDeclaredDurableFinalCapabilities,
|
||||
listDeclaredLivePreviewFinalizerCapabilities,
|
||||
listDeclaredReceiveAckPolicies,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
verifyChannelMessageLiveCapabilityProofs,
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs,
|
||||
verifyChannelMessageReceiveAckPolicyProofs,
|
||||
verifyDurableFinalCapabilityProofs,
|
||||
verifyLivePreviewFinalizerCapabilityProofs,
|
||||
} from "./contracts.js";
|
||||
|
||||
describe("durable final capability contracts", () => {
|
||||
it("lists declared durable-final capabilities in stable order", () => {
|
||||
expect(
|
||||
listDeclaredDurableFinalCapabilities({
|
||||
batch: true,
|
||||
afterCommit: true,
|
||||
reconcileUnknownSend: true,
|
||||
text: true,
|
||||
silent: false,
|
||||
thread: true,
|
||||
}),
|
||||
).toEqual(["text", "thread", "batch", "reconcileUnknownSend", "afterCommit"]);
|
||||
});
|
||||
|
||||
it("runs proofs for every declared durable-final capability", async () => {
|
||||
const text = vi.fn();
|
||||
const silent = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
verifyDurableFinalCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
capabilities: {
|
||||
text: true,
|
||||
silent: true,
|
||||
},
|
||||
proofs: {
|
||||
text,
|
||||
silent,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "silent", status: "verified" },
|
||||
]),
|
||||
);
|
||||
expect(text).toHaveBeenCalledTimes(1);
|
||||
expect(silent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails when a declared durable-final capability has no proof", async () => {
|
||||
await expect(
|
||||
verifyDurableFinalCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
capabilities: {
|
||||
text: true,
|
||||
nativeQuote: true,
|
||||
},
|
||||
proofs: {
|
||||
text: () => {},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'demo declares durable final capability "nativeQuote" without a contract proof',
|
||||
);
|
||||
});
|
||||
|
||||
it("runs proofs from channel message adapter declarations", async () => {
|
||||
const text = vi.fn();
|
||||
const media = vi.fn();
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
adapter: {
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
proofs: {
|
||||
text,
|
||||
media,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
]),
|
||||
);
|
||||
expect(text).toHaveBeenCalledTimes(1);
|
||||
expect(media).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs live preview finalizer proofs from channel message adapter declarations", async () => {
|
||||
const finalEdit = vi.fn();
|
||||
const normalFallback = vi.fn();
|
||||
|
||||
expect(
|
||||
listDeclaredLivePreviewFinalizerCapabilities({
|
||||
previewReceipt: false,
|
||||
normalFallback: true,
|
||||
finalEdit: true,
|
||||
}),
|
||||
).toEqual(["finalEdit", "normalFallback"]);
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageLiveFinalizerProofs({
|
||||
adapterName: "demo",
|
||||
adapter: {
|
||||
live: {
|
||||
finalizer: {
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
normalFallback: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
proofs: {
|
||||
finalEdit,
|
||||
normalFallback,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ capability: "finalEdit", status: "verified" },
|
||||
{ capability: "normalFallback", status: "verified" },
|
||||
]),
|
||||
);
|
||||
expect(finalEdit).toHaveBeenCalledTimes(1);
|
||||
expect(normalFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs live capability proofs from channel message adapter declarations", async () => {
|
||||
const draftPreview = vi.fn();
|
||||
const previewFinalization = vi.fn();
|
||||
|
||||
expect(
|
||||
listDeclaredChannelMessageLiveCapabilities({
|
||||
nativeStreaming: false,
|
||||
previewFinalization: true,
|
||||
draftPreview: true,
|
||||
}),
|
||||
).toEqual(["draftPreview", "previewFinalization"]);
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs({
|
||||
adapterName: "demo",
|
||||
adapter: {
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
proofs: {
|
||||
draftPreview,
|
||||
previewFinalization,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ capability: "draftPreview", status: "verified" },
|
||||
{ capability: "previewFinalization", status: "verified" },
|
||||
]),
|
||||
);
|
||||
expect(draftPreview).toHaveBeenCalledTimes(1);
|
||||
expect(previewFinalization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails when a declared live preview finalizer capability has no proof", async () => {
|
||||
await expect(
|
||||
verifyLivePreviewFinalizerCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
previewReceipt: true,
|
||||
},
|
||||
proofs: {
|
||||
finalEdit: () => {},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'demo declares live preview finalizer capability "previewReceipt" without a contract proof',
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when a declared live capability has no proof", async () => {
|
||||
await expect(
|
||||
verifyChannelMessageLiveCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
progressUpdates: true,
|
||||
},
|
||||
proofs: {
|
||||
draftPreview: () => {},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('demo declares live capability "progressUpdates" without a contract proof');
|
||||
});
|
||||
|
||||
it("runs receive ack policy proofs from channel message adapter declarations", async () => {
|
||||
const afterReceiveRecord = vi.fn();
|
||||
const afterAgentDispatch = vi.fn();
|
||||
|
||||
expect(
|
||||
listDeclaredReceiveAckPolicies({
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_agent_dispatch", "after_receive_record"],
|
||||
}),
|
||||
).toEqual(["after_receive_record", "after_agent_dispatch"]);
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs({
|
||||
adapterName: "demo",
|
||||
adapter: {
|
||||
receive: {
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
},
|
||||
},
|
||||
proofs: {
|
||||
after_receive_record: afterReceiveRecord,
|
||||
after_agent_dispatch: afterAgentDispatch,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ policy: "after_receive_record", status: "verified" },
|
||||
{ policy: "after_agent_dispatch", status: "verified" },
|
||||
]),
|
||||
);
|
||||
expect(afterReceiveRecord).toHaveBeenCalledTimes(1);
|
||||
expect(afterAgentDispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the default receive ack policy when supported policies are omitted", () => {
|
||||
expect(
|
||||
listDeclaredReceiveAckPolicies({
|
||||
defaultAckPolicy: "after_durable_send",
|
||||
}),
|
||||
).toEqual(["after_durable_send"]);
|
||||
});
|
||||
|
||||
it("treats manual receive acknowledgement as an explicit plugin-owned policy", async () => {
|
||||
const manual = vi.fn();
|
||||
|
||||
expect(
|
||||
listDeclaredReceiveAckPolicies({
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
}),
|
||||
).toEqual(["manual"]);
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageReceiveAckPolicyProofs({
|
||||
adapterName: "demo",
|
||||
receive: {
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
},
|
||||
proofs: { manual },
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([{ policy: "manual", status: "verified" }]));
|
||||
expect(manual).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails when a declared receive ack policy has no proof", async () => {
|
||||
await expect(
|
||||
verifyChannelMessageReceiveAckPolicyProofs({
|
||||
adapterName: "demo",
|
||||
receive: {
|
||||
supportedAckPolicies: ["after_receive_record", "manual"],
|
||||
},
|
||||
proofs: {
|
||||
after_receive_record: () => {},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('demo declares receive ack policy "manual" without a contract proof');
|
||||
});
|
||||
});
|
||||
233
src/channels/message/contracts.ts
Normal file
233
src/channels/message/contracts.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
ChannelMessageAdapterShape,
|
||||
ChannelMessageLiveCapability,
|
||||
ChannelMessageReceiveAckPolicy,
|
||||
DurableFinalDeliveryCapability,
|
||||
DurableFinalDeliveryRequirementMap,
|
||||
LivePreviewFinalizerCapability,
|
||||
LivePreviewFinalizerCapabilityMap,
|
||||
} from "./types.js";
|
||||
import {
|
||||
channelMessageLiveCapabilities,
|
||||
channelMessageReceiveAckPolicies,
|
||||
durableFinalDeliveryCapabilities,
|
||||
livePreviewFinalizerCapabilities,
|
||||
} from "./types.js";
|
||||
|
||||
export type DurableFinalCapabilityProof = () => Promise<void> | void;
|
||||
|
||||
export type DurableFinalCapabilityProofMap = Partial<
|
||||
Record<DurableFinalDeliveryCapability, DurableFinalCapabilityProof>
|
||||
>;
|
||||
|
||||
export type DurableFinalCapabilityProofResult = {
|
||||
capability: DurableFinalDeliveryCapability;
|
||||
status: "verified" | "not_declared";
|
||||
};
|
||||
|
||||
export type LivePreviewFinalizerCapabilityProof = () => Promise<void> | void;
|
||||
|
||||
export type ChannelMessageLiveCapabilityProof = () => Promise<void> | void;
|
||||
|
||||
export type ChannelMessageReceiveAckPolicyProof = () => Promise<void> | void;
|
||||
|
||||
export type LivePreviewFinalizerCapabilityProofMap = Partial<
|
||||
Record<LivePreviewFinalizerCapability, LivePreviewFinalizerCapabilityProof>
|
||||
>;
|
||||
|
||||
export type ChannelMessageLiveCapabilityProofMap = Partial<
|
||||
Record<ChannelMessageLiveCapability, ChannelMessageLiveCapabilityProof>
|
||||
>;
|
||||
|
||||
export type ChannelMessageReceiveAckPolicyProofMap = Partial<
|
||||
Record<ChannelMessageReceiveAckPolicy, ChannelMessageReceiveAckPolicyProof>
|
||||
>;
|
||||
|
||||
export type LivePreviewFinalizerCapabilityProofResult = {
|
||||
capability: LivePreviewFinalizerCapability;
|
||||
status: "verified" | "not_declared";
|
||||
};
|
||||
|
||||
export type ChannelMessageLiveCapabilityProofResult = {
|
||||
capability: ChannelMessageLiveCapability;
|
||||
status: "verified" | "not_declared";
|
||||
};
|
||||
|
||||
export type ChannelMessageReceiveAckPolicyProofResult = {
|
||||
policy: ChannelMessageReceiveAckPolicy;
|
||||
status: "verified" | "not_declared";
|
||||
};
|
||||
|
||||
export function listDeclaredDurableFinalCapabilities(
|
||||
capabilities: DurableFinalDeliveryRequirementMap | undefined,
|
||||
): DurableFinalDeliveryCapability[] {
|
||||
return durableFinalDeliveryCapabilities.filter(
|
||||
(capability) => capabilities?.[capability] === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function listDeclaredLivePreviewFinalizerCapabilities(
|
||||
capabilities: LivePreviewFinalizerCapabilityMap | undefined,
|
||||
): LivePreviewFinalizerCapability[] {
|
||||
return livePreviewFinalizerCapabilities.filter(
|
||||
(capability) => capabilities?.[capability] === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function listDeclaredChannelMessageLiveCapabilities(
|
||||
capabilities: Partial<Record<ChannelMessageLiveCapability, boolean>> | undefined,
|
||||
): ChannelMessageLiveCapability[] {
|
||||
return channelMessageLiveCapabilities.filter((capability) => capabilities?.[capability] === true);
|
||||
}
|
||||
|
||||
export function listDeclaredReceiveAckPolicies(
|
||||
receive: ChannelMessageAdapterShape["receive"] | undefined,
|
||||
): ChannelMessageReceiveAckPolicy[] {
|
||||
const declared = receive?.supportedAckPolicies?.length
|
||||
? receive.supportedAckPolicies
|
||||
: receive?.defaultAckPolicy
|
||||
? [receive.defaultAckPolicy]
|
||||
: [];
|
||||
return channelMessageReceiveAckPolicies.filter((policy) => declared.includes(policy));
|
||||
}
|
||||
|
||||
export async function verifyDurableFinalCapabilityProofs(params: {
|
||||
adapterName: string;
|
||||
capabilities?: DurableFinalDeliveryRequirementMap;
|
||||
proofs: DurableFinalCapabilityProofMap;
|
||||
}): Promise<DurableFinalCapabilityProofResult[]> {
|
||||
const results: DurableFinalCapabilityProofResult[] = [];
|
||||
for (const capability of durableFinalDeliveryCapabilities) {
|
||||
if (params.capabilities?.[capability] !== true) {
|
||||
results.push({ capability, status: "not_declared" });
|
||||
continue;
|
||||
}
|
||||
const proof = params.proofs[capability];
|
||||
if (!proof) {
|
||||
throw new Error(
|
||||
`${params.adapterName} declares durable final capability "${capability}" without a contract proof`,
|
||||
);
|
||||
}
|
||||
await proof();
|
||||
results.push({ capability, status: "verified" });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
|
||||
adapterName: string;
|
||||
capabilities?: LivePreviewFinalizerCapabilityMap;
|
||||
proofs: LivePreviewFinalizerCapabilityProofMap;
|
||||
}): Promise<LivePreviewFinalizerCapabilityProofResult[]> {
|
||||
const results: LivePreviewFinalizerCapabilityProofResult[] = [];
|
||||
for (const capability of livePreviewFinalizerCapabilities) {
|
||||
if (params.capabilities?.[capability] !== true) {
|
||||
results.push({ capability, status: "not_declared" });
|
||||
continue;
|
||||
}
|
||||
const proof = params.proofs[capability];
|
||||
if (!proof) {
|
||||
throw new Error(
|
||||
`${params.adapterName} declares live preview finalizer capability "${capability}" without a contract proof`,
|
||||
);
|
||||
}
|
||||
await proof();
|
||||
results.push({ capability, status: "verified" });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageLiveCapabilityProofs(params: {
|
||||
adapterName: string;
|
||||
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
|
||||
proofs: ChannelMessageLiveCapabilityProofMap;
|
||||
}): Promise<ChannelMessageLiveCapabilityProofResult[]> {
|
||||
const results: ChannelMessageLiveCapabilityProofResult[] = [];
|
||||
for (const capability of channelMessageLiveCapabilities) {
|
||||
if (params.capabilities?.[capability] !== true) {
|
||||
results.push({ capability, status: "not_declared" });
|
||||
continue;
|
||||
}
|
||||
const proof = params.proofs[capability];
|
||||
if (!proof) {
|
||||
throw new Error(
|
||||
`${params.adapterName} declares live capability "${capability}" without a contract proof`,
|
||||
);
|
||||
}
|
||||
await proof();
|
||||
results.push({ capability, status: "verified" });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
|
||||
adapterName: string;
|
||||
receive?: ChannelMessageAdapterShape["receive"];
|
||||
proofs: ChannelMessageReceiveAckPolicyProofMap;
|
||||
}): Promise<ChannelMessageReceiveAckPolicyProofResult[]> {
|
||||
const declared = new Set(listDeclaredReceiveAckPolicies(params.receive));
|
||||
const results: ChannelMessageReceiveAckPolicyProofResult[] = [];
|
||||
for (const policy of channelMessageReceiveAckPolicies) {
|
||||
if (!declared.has(policy)) {
|
||||
results.push({ policy, status: "not_declared" });
|
||||
continue;
|
||||
}
|
||||
const proof = params.proofs[policy];
|
||||
if (!proof) {
|
||||
throw new Error(
|
||||
`${params.adapterName} declares receive ack policy "${policy}" without a contract proof`,
|
||||
);
|
||||
}
|
||||
await proof();
|
||||
results.push({ policy, status: "verified" });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageAdapterCapabilityProofs(params: {
|
||||
adapterName: string;
|
||||
adapter: Pick<ChannelMessageAdapterShape, "durableFinal">;
|
||||
proofs: DurableFinalCapabilityProofMap;
|
||||
}): Promise<DurableFinalCapabilityProofResult[]> {
|
||||
return await verifyDurableFinalCapabilityProofs({
|
||||
adapterName: params.adapterName,
|
||||
capabilities: params.adapter.durableFinal?.capabilities,
|
||||
proofs: params.proofs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: {
|
||||
adapterName: string;
|
||||
adapter: Pick<ChannelMessageAdapterShape, "receive">;
|
||||
proofs: ChannelMessageReceiveAckPolicyProofMap;
|
||||
}): Promise<ChannelMessageReceiveAckPolicyProofResult[]> {
|
||||
return await verifyChannelMessageReceiveAckPolicyProofs({
|
||||
adapterName: params.adapterName,
|
||||
receive: params.adapter.receive,
|
||||
proofs: params.proofs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageLiveFinalizerProofs(params: {
|
||||
adapterName: string;
|
||||
adapter: Pick<ChannelMessageAdapterShape, "live">;
|
||||
proofs: LivePreviewFinalizerCapabilityProofMap;
|
||||
}): Promise<LivePreviewFinalizerCapabilityProofResult[]> {
|
||||
return await verifyLivePreviewFinalizerCapabilityProofs({
|
||||
adapterName: params.adapterName,
|
||||
capabilities: params.adapter.live?.finalizer?.capabilities,
|
||||
proofs: params.proofs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: {
|
||||
adapterName: string;
|
||||
adapter: Pick<ChannelMessageAdapterShape, "live">;
|
||||
proofs: ChannelMessageLiveCapabilityProofMap;
|
||||
}): Promise<ChannelMessageLiveCapabilityProofResult[]> {
|
||||
return await verifyChannelMessageLiveCapabilityProofs({
|
||||
adapterName: params.adapterName,
|
||||
capabilities: params.adapter.live?.capabilities,
|
||||
proofs: params.proofs,
|
||||
});
|
||||
}
|
||||
125
src/channels/message/index.ts
Normal file
125
src/channels/message/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export { deriveDurableFinalDeliveryRequirements } from "./capabilities.js";
|
||||
export { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js";
|
||||
export {
|
||||
listDeclaredChannelMessageLiveCapabilities,
|
||||
listDeclaredDurableFinalCapabilities,
|
||||
listDeclaredLivePreviewFinalizerCapabilities,
|
||||
listDeclaredReceiveAckPolicies,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
verifyChannelMessageLiveCapabilityProofs,
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs,
|
||||
verifyChannelMessageReceiveAckPolicyProofs,
|
||||
verifyDurableFinalCapabilityProofs,
|
||||
verifyLivePreviewFinalizerCapabilityProofs,
|
||||
} from "./contracts.js";
|
||||
export {
|
||||
createLiveMessageState,
|
||||
createPreviewMessageReceipt,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverFinalizableLivePreview,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
markLiveMessageCancelled,
|
||||
markLiveMessageFinalized,
|
||||
markLiveMessagePreviewUpdated,
|
||||
} from "./live.js";
|
||||
export {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
listMessageReceiptPlatformIds,
|
||||
resolveMessageReceiptPrimaryId,
|
||||
} from "./receipt.js";
|
||||
export { createMessageReceiveContext, shouldAckMessageAfterStage } from "./receive.js";
|
||||
export {
|
||||
createChannelReplyPipeline,
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "./reply-pipeline.js";
|
||||
export { classifyDurableSendRecoveryState, createDurableMessageStateRecord } from "./state.js";
|
||||
export type {
|
||||
ChannelMessageOutboundBridgeAdapter,
|
||||
ChannelMessageOutboundBridgeResult,
|
||||
CreateChannelMessageAdapterFromOutboundParams,
|
||||
} from "./outbound-bridge.js";
|
||||
export type {
|
||||
ChannelMessageLiveCapabilityProof,
|
||||
ChannelMessageLiveCapabilityProofMap,
|
||||
ChannelMessageLiveCapabilityProofResult,
|
||||
ChannelMessageReceiveAckPolicyProof,
|
||||
ChannelMessageReceiveAckPolicyProofMap,
|
||||
ChannelMessageReceiveAckPolicyProofResult,
|
||||
DurableFinalCapabilityProof,
|
||||
DurableFinalCapabilityProofMap,
|
||||
DurableFinalCapabilityProofResult,
|
||||
LivePreviewFinalizerCapabilityProof,
|
||||
LivePreviewFinalizerCapabilityProofMap,
|
||||
LivePreviewFinalizerCapabilityProofResult,
|
||||
} from "./contracts.js";
|
||||
export type {
|
||||
ChannelReplyPipeline,
|
||||
CreateChannelReplyPipelineParams,
|
||||
CreateTypingCallbacksParams,
|
||||
ReplyPrefixContext,
|
||||
ReplyPrefixContextBundle,
|
||||
ReplyPrefixOptions,
|
||||
SourceReplyDeliveryMode,
|
||||
TypingCallbacks,
|
||||
} from "./reply-pipeline.js";
|
||||
export type {
|
||||
MessageAckPolicy,
|
||||
MessageAckStage,
|
||||
MessageAckState,
|
||||
MessageReceiveContext,
|
||||
} from "./receive.js";
|
||||
export type {
|
||||
LivePreviewFinalizerDraft,
|
||||
FinalizableLivePreviewAdapter,
|
||||
LivePreviewFinalizerResult,
|
||||
LivePreviewFinalizerResultKind,
|
||||
} from "./live.js";
|
||||
export type { DurableMessageSendState, DurableMessageStateRecord } from "./state.js";
|
||||
export type {
|
||||
ChannelMessageAdapter,
|
||||
ChannelMessageAdapterShape,
|
||||
ChannelMessageDurableFinalAdapter,
|
||||
ChannelMessageLiveFinalizerAdapterShape,
|
||||
ChannelMessageLiveAdapterShape,
|
||||
ChannelMessageLiveCapability,
|
||||
ChannelMessageReceiveAckPolicy,
|
||||
ChannelMessageReceiveAdapterShape,
|
||||
ChannelMessageSendAdapter,
|
||||
ChannelMessageSendAttemptContext,
|
||||
ChannelMessageSendAttemptKind,
|
||||
ChannelMessageSendCommitContext,
|
||||
ChannelMessageSendFailureContext,
|
||||
ChannelMessageSendLifecycleAdapter,
|
||||
ChannelMessageSendMediaContext,
|
||||
ChannelMessageSendPayloadContext,
|
||||
ChannelMessageSendResult,
|
||||
ChannelMessageSendSuccessContext,
|
||||
ChannelMessageSendTextContext,
|
||||
ChannelMessageUnknownSendContext,
|
||||
ChannelMessageUnknownSendReconciliationResult,
|
||||
DeriveDurableFinalDeliveryRequirementsParams,
|
||||
DurableFinalDeliveryCapability,
|
||||
DurableFinalDeliveryPayloadShape,
|
||||
DurableFinalDeliveryRequirementMap,
|
||||
DurableFinalRequirementExtras,
|
||||
DurableMessageSendIntent,
|
||||
MessageSendContext,
|
||||
MessageDurabilityPolicy,
|
||||
LiveMessagePhase,
|
||||
LiveMessageState,
|
||||
LivePreviewFinalizerCapability,
|
||||
LivePreviewFinalizerCapabilityMap,
|
||||
MessageReceipt,
|
||||
MessageReceiptPart,
|
||||
MessageReceiptPartKind,
|
||||
MessageReceiptSourceResult,
|
||||
RenderedMessageBatch,
|
||||
RenderedMessageBatchPlan,
|
||||
RenderedMessageBatchPlanItem,
|
||||
RenderedMessageBatchPlanKind,
|
||||
} from "./types.js";
|
||||
356
src/channels/message/lifecycle.test.ts
Normal file
356
src/channels/message/lifecycle.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createLiveMessageState,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverFinalizableLivePreview,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
markLiveMessageCancelled,
|
||||
markLiveMessageFinalized,
|
||||
markLiveMessagePreviewUpdated,
|
||||
} from "./live.js";
|
||||
import { createMessageReceiveContext, shouldAckMessageAfterStage } from "./receive.js";
|
||||
import { classifyDurableSendRecoveryState, createDurableMessageStateRecord } from "./state.js";
|
||||
|
||||
describe("message lifecycle primitives", () => {
|
||||
it("tracks live preview finalization state", () => {
|
||||
const receipt = {
|
||||
primaryPlatformMessageId: "m1",
|
||||
platformMessageIds: ["m1"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
const preview = createLiveMessageState({ receipt });
|
||||
expect(preview).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "previewing",
|
||||
canFinalizeInPlace: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(markLiveMessageFinalized(preview, receipt)).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "finalized",
|
||||
canFinalizeInPlace: false,
|
||||
}),
|
||||
);
|
||||
expect(markLiveMessageCancelled(preview)).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "cancelled",
|
||||
canFinalizeInPlace: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks live preview rendered batch updates", () => {
|
||||
const preview = createLiveMessageState();
|
||||
const rendered = {
|
||||
payloads: [{ text: "draft" }],
|
||||
plan: {
|
||||
payloadCount: 1,
|
||||
textCount: 1,
|
||||
mediaCount: 0,
|
||||
voiceCount: 0,
|
||||
presentationCount: 0,
|
||||
interactiveCount: 0,
|
||||
channelDataCount: 0,
|
||||
items: [{ index: 0, kinds: ["text"] as const, text: "draft", mediaUrls: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(markLiveMessagePreviewUpdated(preview, rendered)).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "previewing",
|
||||
lastRendered: rendered,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("finalizes live previews in place with preview receipts", async () => {
|
||||
const editFinal = vi.fn(async () => undefined);
|
||||
const deliverNormally = vi.fn(async () => undefined);
|
||||
const onPreviewFinalized = vi.fn(async () => undefined);
|
||||
|
||||
const result = await deliverFinalizableLivePreview({
|
||||
kind: "final",
|
||||
payload: { text: "done" },
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-1",
|
||||
seal: vi.fn(async () => undefined),
|
||||
clear: vi.fn(async () => undefined),
|
||||
},
|
||||
buildFinalEdit: (payload) => ({ text: payload.text }),
|
||||
editFinal,
|
||||
deliverNormally,
|
||||
onPreviewFinalized,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("preview-finalized");
|
||||
expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" });
|
||||
expect(deliverNormally).not.toHaveBeenCalled();
|
||||
expect(result.liveState).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "finalized",
|
||||
canFinalizeInPlace: false,
|
||||
receipt: expect.objectContaining({
|
||||
primaryPlatformMessageId: "preview-1",
|
||||
platformMessageIds: ["preview-1"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onPreviewFinalized).toHaveBeenCalledWith(
|
||||
"preview-1",
|
||||
expect.objectContaining({ primaryPlatformMessageId: "preview-1" }),
|
||||
result.liveState,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats live preview fallback delivery as terminal state", async () => {
|
||||
const discardPending = vi.fn(async () => undefined);
|
||||
const clear = vi.fn(async () => undefined);
|
||||
const deliverNormally = vi.fn(async () => true);
|
||||
|
||||
const result = await deliverFinalizableLivePreview({
|
||||
kind: "final",
|
||||
payload: { text: "with media" },
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-2",
|
||||
discardPending,
|
||||
clear,
|
||||
},
|
||||
buildFinalEdit: () => undefined,
|
||||
editFinal: vi.fn(async () => undefined),
|
||||
deliverNormally,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("normal-delivered");
|
||||
expect(discardPending).toHaveBeenCalledTimes(1);
|
||||
expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" });
|
||||
expect(clear).toHaveBeenCalledTimes(1);
|
||||
expect(result.liveState).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: "cancelled",
|
||||
canFinalizeInPlace: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers through finalizable live preview adapters", async () => {
|
||||
const editFinal = vi.fn(async () => undefined);
|
||||
const adapter = defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-adapter-1",
|
||||
clear: vi.fn(async () => undefined),
|
||||
},
|
||||
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text.toUpperCase() }),
|
||||
editFinal,
|
||||
});
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: "final",
|
||||
payload: { text: "done" },
|
||||
adapter,
|
||||
deliverNormally: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("preview-finalized");
|
||||
expect(editFinal).toHaveBeenCalledWith("preview-adapter-1", { text: "DONE" });
|
||||
});
|
||||
|
||||
it("lets live preview adapters resolve the committed platform id after final edit", async () => {
|
||||
const adapter = defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-before-edit",
|
||||
clear: vi.fn(async () => undefined),
|
||||
},
|
||||
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
|
||||
editFinal: vi.fn(async () => undefined),
|
||||
resolveFinalizedId: () => "message-after-edit",
|
||||
});
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: "final",
|
||||
payload: { text: "done" },
|
||||
adapter,
|
||||
deliverNormally: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(result.liveState?.receipt?.primaryPlatformMessageId).toBe("message-after-edit");
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when no live preview adapter is available", async () => {
|
||||
const deliverNormally = vi.fn(async () => undefined);
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: "final",
|
||||
payload: { text: "plain" },
|
||||
deliverNormally,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("normal-delivered");
|
||||
expect(deliverNormally).toHaveBeenCalledWith({ text: "plain" });
|
||||
});
|
||||
|
||||
it("lets live preview adapters retain ambiguous failed final edits without fallback send", async () => {
|
||||
const deliverNormally = vi.fn(async () => undefined);
|
||||
const handlePreviewEditError = vi.fn(() => "retain" as const);
|
||||
const editError = new Error("timeout after request");
|
||||
const adapter = defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-maybe-final",
|
||||
clear: vi.fn(async () => undefined),
|
||||
},
|
||||
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
|
||||
editFinal: vi.fn(async () => {
|
||||
throw editError;
|
||||
}),
|
||||
handlePreviewEditError,
|
||||
});
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: "final",
|
||||
payload: { text: "done" },
|
||||
adapter,
|
||||
deliverNormally,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(result.liveState?.phase).toBe("previewing");
|
||||
expect(deliverNormally).not.toHaveBeenCalled();
|
||||
expect(handlePreviewEditError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: editError,
|
||||
id: "preview-maybe-final",
|
||||
edit: { text: "done" },
|
||||
payload: { text: "done" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fallback-send after a successful preview edit when finalization hooks fail", async () => {
|
||||
const deliverNormally = vi.fn(async () => undefined);
|
||||
const onPreviewFinalized = vi.fn(async () => {
|
||||
throw new Error("receipt side effect failed");
|
||||
});
|
||||
const editFinal = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
deliverFinalizableLivePreview({
|
||||
kind: "final",
|
||||
payload: { text: "done" },
|
||||
draft: {
|
||||
flush: vi.fn(async () => undefined),
|
||||
id: () => "preview-finalized-before-hook",
|
||||
seal: vi.fn(async () => undefined),
|
||||
clear: vi.fn(async () => undefined),
|
||||
},
|
||||
buildFinalEdit: (payload) => ({ text: payload.text }),
|
||||
editFinal,
|
||||
deliverNormally,
|
||||
onPreviewFinalized,
|
||||
}),
|
||||
).rejects.toThrow("receipt side effect failed");
|
||||
|
||||
expect(editFinal).toHaveBeenCalledWith("preview-finalized-before-hook", { text: "done" });
|
||||
expect(deliverNormally).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates receive contexts with explicit ack policy defaults", () => {
|
||||
const ctx = createMessageReceiveContext({
|
||||
id: "rx-1",
|
||||
channel: "telegram",
|
||||
message: { text: "hello" },
|
||||
receivedAt: 123,
|
||||
});
|
||||
|
||||
expect(ctx).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "rx-1",
|
||||
channel: "telegram",
|
||||
message: { text: "hello" },
|
||||
ackPolicy: "after_receive_record",
|
||||
ackState: "pending",
|
||||
receivedAt: 123,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("acks and nacks receive contexts through explicit hooks", async () => {
|
||||
const onAck = vi.fn(async () => undefined);
|
||||
const onNack = vi.fn(async () => undefined);
|
||||
const ctx = createMessageReceiveContext({
|
||||
id: "rx-ack",
|
||||
channel: "telegram",
|
||||
message: { text: "hello" },
|
||||
ackPolicy: "after_durable_send",
|
||||
onAck,
|
||||
onNack,
|
||||
});
|
||||
|
||||
expect(ctx.shouldAckAfter("receive_record")).toBe(false);
|
||||
expect(ctx.shouldAckAfter("durable_send")).toBe(true);
|
||||
|
||||
await ctx.ack();
|
||||
await ctx.ack();
|
||||
expect(onAck).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.ackState).toBe("acked");
|
||||
expect(ctx.ackedAt).toEqual(expect.any(Number));
|
||||
|
||||
await ctx.nack(new Error("offset failed"));
|
||||
expect(onNack).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(ctx.ackState).toBe("nacked");
|
||||
expect(ctx.nackErrorMessage).toBe("offset failed");
|
||||
});
|
||||
|
||||
it("maps ack policies to lifecycle stages", () => {
|
||||
expect(shouldAckMessageAfterStage("after_receive_record", "receive_record")).toBe(true);
|
||||
expect(shouldAckMessageAfterStage("after_receive_record", "agent_dispatch")).toBe(false);
|
||||
expect(shouldAckMessageAfterStage("after_agent_dispatch", "agent_dispatch")).toBe(true);
|
||||
expect(shouldAckMessageAfterStage("after_durable_send", "durable_send")).toBe(true);
|
||||
expect(shouldAckMessageAfterStage("manual", "manual")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies unknown-after-send recovery only after platform send may have started", () => {
|
||||
expect(
|
||||
classifyDurableSendRecoveryState({
|
||||
hasIntent: true,
|
||||
hasReceipt: false,
|
||||
platformSendMayHaveStarted: true,
|
||||
}),
|
||||
).toBe("unknown_after_send");
|
||||
expect(
|
||||
classifyDurableSendRecoveryState({
|
||||
hasIntent: true,
|
||||
hasReceipt: false,
|
||||
platformSendMayHaveStarted: false,
|
||||
}),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("creates durable message state records with normalized errors", () => {
|
||||
expect(
|
||||
createDurableMessageStateRecord({
|
||||
intent: {
|
||||
id: "intent-1",
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
durability: "required",
|
||||
},
|
||||
state: "failed",
|
||||
error: new Error("network"),
|
||||
updatedAt: 123,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
state: "failed",
|
||||
errorMessage: "network",
|
||||
updatedAt: 123,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
273
src/channels/message/live.ts
Normal file
273
src/channels/message/live.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { LiveMessageState, MessageReceipt, RenderedMessageBatch } from "./types.js";
|
||||
export type { LiveMessagePhase, LiveMessageState } from "./types.js";
|
||||
|
||||
export type LivePreviewFinalizerDraft<TId> = {
|
||||
flush: () => Promise<void>;
|
||||
id: () => TId | undefined;
|
||||
seal?: () => Promise<void>;
|
||||
discardPending?: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type LivePreviewFinalizerResultKind =
|
||||
| "normal-delivered"
|
||||
| "normal-skipped"
|
||||
| "preview-finalized"
|
||||
| "preview-retained";
|
||||
|
||||
export type LivePreviewFinalizerResult<TPayload> = {
|
||||
kind: LivePreviewFinalizerResultKind;
|
||||
liveState?: LiveMessageState<TPayload>;
|
||||
};
|
||||
|
||||
export type FinalizableLivePreviewAdapter<TPayload, TId, TEdit> = {
|
||||
draft?: LivePreviewFinalizerDraft<TId>;
|
||||
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
|
||||
editFinal: (id: TId, edit: TEdit) => Promise<void>;
|
||||
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
|
||||
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
|
||||
onPreviewFinalized?: (
|
||||
id: TId,
|
||||
receipt: MessageReceipt,
|
||||
liveState: LiveMessageState<TPayload>,
|
||||
) => Promise<void> | void;
|
||||
handlePreviewEditError?: (params: {
|
||||
error: unknown;
|
||||
id: TId;
|
||||
edit: TEdit;
|
||||
payload: TPayload;
|
||||
liveState: LiveMessageState<TPayload>;
|
||||
}) => "fallback" | "retain" | Promise<"fallback" | "retain">;
|
||||
logPreviewEditFailure?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export function defineFinalizableLivePreviewAdapter<TPayload, TId, TEdit>(
|
||||
adapter: FinalizableLivePreviewAdapter<TPayload, TId, TEdit>,
|
||||
): FinalizableLivePreviewAdapter<TPayload, TId, TEdit> {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function createLiveMessageState<TPayload = unknown>(params?: {
|
||||
receipt?: MessageReceipt;
|
||||
lastRendered?: RenderedMessageBatch<TPayload>;
|
||||
canFinalizeInPlace?: boolean;
|
||||
}): LiveMessageState<TPayload> {
|
||||
return {
|
||||
phase: params?.receipt ? "previewing" : "idle",
|
||||
canFinalizeInPlace: params?.canFinalizeInPlace ?? Boolean(params?.receipt),
|
||||
...(params?.receipt ? { receipt: params.receipt } : {}),
|
||||
...(params?.lastRendered ? { lastRendered: params.lastRendered } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function markLiveMessageFinalized<TPayload>(
|
||||
state: LiveMessageState<TPayload>,
|
||||
receipt: MessageReceipt,
|
||||
): LiveMessageState<TPayload> {
|
||||
return {
|
||||
...state,
|
||||
phase: "finalized",
|
||||
receipt,
|
||||
canFinalizeInPlace: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPreviewMessageReceipt(params: {
|
||||
id: unknown;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
sentAt?: number;
|
||||
raw?: unknown;
|
||||
}): MessageReceipt {
|
||||
const platformMessageId = String(params.id);
|
||||
return {
|
||||
primaryPlatformMessageId: platformMessageId,
|
||||
platformMessageIds: [platformMessageId],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId,
|
||||
kind: "preview",
|
||||
index: 0,
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
},
|
||||
],
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
sentAt: params.sentAt ?? Date.now(),
|
||||
...(params.raw === undefined ? {} : { raw: [{ meta: { raw: params.raw } }] }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deliverFinalizableLivePreview<TPayload, TId, TEdit>(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
payload: TPayload;
|
||||
liveState?: LiveMessageState<TPayload>;
|
||||
draft?: LivePreviewFinalizerDraft<TId>;
|
||||
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
|
||||
editFinal: (id: TId, edit: TEdit) => Promise<void>;
|
||||
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
|
||||
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
|
||||
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
|
||||
onPreviewFinalized?: (
|
||||
id: TId,
|
||||
receipt: MessageReceipt,
|
||||
liveState: LiveMessageState<TPayload>,
|
||||
) => Promise<void> | void;
|
||||
handlePreviewEditError?: (params: {
|
||||
error: unknown;
|
||||
id: TId;
|
||||
edit: TEdit;
|
||||
payload: TPayload;
|
||||
liveState: LiveMessageState<TPayload>;
|
||||
}) => "fallback" | "retain" | Promise<"fallback" | "retain">;
|
||||
onNormalDelivered?: () => Promise<void> | void;
|
||||
logPreviewEditFailure?: (error: unknown) => void;
|
||||
}): Promise<LivePreviewFinalizerResult<TPayload>> {
|
||||
let liveState =
|
||||
params.liveState ??
|
||||
createLiveMessageState<TPayload>({ canFinalizeInPlace: Boolean(params.draft) });
|
||||
|
||||
if (params.kind !== "final" || !params.draft) {
|
||||
const delivered = await params.deliverNormally(params.payload);
|
||||
if (delivered === false) {
|
||||
return { kind: "normal-skipped", liveState };
|
||||
}
|
||||
await params.onNormalDelivered?.();
|
||||
return { kind: "normal-delivered", liveState };
|
||||
}
|
||||
|
||||
const edit = liveState.canFinalizeInPlace ? params.buildFinalEdit(params.payload) : undefined;
|
||||
if (edit !== undefined) {
|
||||
await params.draft.flush();
|
||||
const previewId = params.draft.id();
|
||||
if (previewId !== undefined) {
|
||||
await params.draft.seal?.();
|
||||
let editSucceeded = false;
|
||||
try {
|
||||
await params.editFinal(previewId, edit);
|
||||
editSucceeded = true;
|
||||
} catch (err) {
|
||||
params.logPreviewEditFailure?.(err);
|
||||
const decision =
|
||||
(await params.handlePreviewEditError?.({
|
||||
error: err,
|
||||
id: previewId,
|
||||
edit,
|
||||
payload: params.payload,
|
||||
liveState,
|
||||
})) ?? "fallback";
|
||||
if (decision === "retain") {
|
||||
const receipt =
|
||||
liveState.receipt ??
|
||||
params.createPreviewReceipt?.(previewId, edit) ??
|
||||
createPreviewMessageReceipt({ id: previewId });
|
||||
liveState = {
|
||||
...liveState,
|
||||
phase: "previewing",
|
||||
canFinalizeInPlace: true,
|
||||
receipt,
|
||||
};
|
||||
return { kind: "preview-retained", liveState };
|
||||
}
|
||||
}
|
||||
if (editSucceeded) {
|
||||
const finalizedId = params.resolveFinalizedId?.(previewId, edit) ?? previewId;
|
||||
const receipt =
|
||||
params.createPreviewReceipt?.(finalizedId, edit) ??
|
||||
createPreviewMessageReceipt({ id: finalizedId });
|
||||
liveState = markLiveMessageFinalized(liveState, receipt);
|
||||
await params.onPreviewFinalized?.(finalizedId, receipt, liveState);
|
||||
return { kind: "preview-finalized", liveState };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.draft.discardPending) {
|
||||
await params.draft.discardPending();
|
||||
} else {
|
||||
await params.draft.clear();
|
||||
}
|
||||
liveState = markLiveMessageCancelled(liveState);
|
||||
|
||||
let delivered = false;
|
||||
try {
|
||||
const result = await params.deliverNormally(params.payload);
|
||||
delivered = result !== false;
|
||||
if (delivered) {
|
||||
await params.onNormalDelivered?.();
|
||||
}
|
||||
} finally {
|
||||
if (delivered) {
|
||||
await params.draft.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: delivered ? "normal-delivered" : "normal-skipped", liveState };
|
||||
}
|
||||
|
||||
export async function deliverWithFinalizableLivePreviewAdapter<TPayload, TId, TEdit>(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
payload: TPayload;
|
||||
liveState?: LiveMessageState<TPayload>;
|
||||
adapter?: FinalizableLivePreviewAdapter<TPayload, TId, TEdit>;
|
||||
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
|
||||
onNormalDelivered?: () => Promise<void> | void;
|
||||
}): Promise<LivePreviewFinalizerResult<TPayload>> {
|
||||
if (!params.adapter) {
|
||||
const liveState = params.liveState ?? createLiveMessageState<TPayload>();
|
||||
const delivered = await params.deliverNormally(params.payload);
|
||||
if (delivered === false) {
|
||||
return { kind: "normal-skipped", liveState };
|
||||
}
|
||||
await params.onNormalDelivered?.();
|
||||
return { kind: "normal-delivered", liveState };
|
||||
}
|
||||
|
||||
return await deliverFinalizableLivePreview({
|
||||
kind: params.kind,
|
||||
payload: params.payload,
|
||||
...(params.liveState ? { liveState: params.liveState } : {}),
|
||||
draft: params.adapter.draft,
|
||||
buildFinalEdit: params.adapter.buildFinalEdit,
|
||||
editFinal: params.adapter.editFinal,
|
||||
...(params.adapter.resolveFinalizedId
|
||||
? { resolveFinalizedId: params.adapter.resolveFinalizedId }
|
||||
: {}),
|
||||
deliverNormally: params.deliverNormally,
|
||||
...(params.adapter.createPreviewReceipt
|
||||
? { createPreviewReceipt: params.adapter.createPreviewReceipt }
|
||||
: {}),
|
||||
...(params.adapter.onPreviewFinalized
|
||||
? { onPreviewFinalized: params.adapter.onPreviewFinalized }
|
||||
: {}),
|
||||
...(params.adapter.handlePreviewEditError
|
||||
? { handlePreviewEditError: params.adapter.handlePreviewEditError }
|
||||
: {}),
|
||||
...(params.onNormalDelivered ? { onNormalDelivered: params.onNormalDelivered } : {}),
|
||||
...(params.adapter.logPreviewEditFailure
|
||||
? { logPreviewEditFailure: params.adapter.logPreviewEditFailure }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function markLiveMessagePreviewUpdated<TPayload>(
|
||||
state: LiveMessageState<TPayload>,
|
||||
rendered: RenderedMessageBatch<TPayload>,
|
||||
): LiveMessageState<TPayload> {
|
||||
return {
|
||||
...state,
|
||||
phase: "previewing",
|
||||
lastRendered: rendered,
|
||||
};
|
||||
}
|
||||
|
||||
export function markLiveMessageCancelled<TPayload>(
|
||||
state: LiveMessageState<TPayload>,
|
||||
): LiveMessageState<TPayload> {
|
||||
return {
|
||||
...state,
|
||||
phase: "cancelled",
|
||||
canFinalizeInPlace: false,
|
||||
};
|
||||
}
|
||||
167
src/channels/message/outbound-bridge.test.ts
Normal file
167
src/channels/message/outbound-bridge.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js";
|
||||
import type { MessageReceipt } from "./types.js";
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
describe("createChannelMessageAdapterFromOutbound", () => {
|
||||
it("wraps outbound text sends with a message receipt", async () => {
|
||||
const sendText = vi.fn(async () => ({ channel: "demo", messageId: "msg-1" }));
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "demo",
|
||||
outbound: {
|
||||
deliveryCapabilities: { durableFinal: { text: true, replyTo: true } },
|
||||
sendText,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await adapter.send?.text?.({
|
||||
cfg,
|
||||
to: "room-1",
|
||||
text: "hello",
|
||||
replyToId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(adapter).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "demo",
|
||||
durableFinal: { capabilities: { text: true, replyTo: true } },
|
||||
}),
|
||||
);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "room-1",
|
||||
text: "hello",
|
||||
replyToId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
messageId: "msg-1",
|
||||
receipt: expect.objectContaining({
|
||||
primaryPlatformMessageId: "msg-1",
|
||||
platformMessageIds: ["msg-1"],
|
||||
threadId: "thread-1",
|
||||
replyToId: "parent-1",
|
||||
parts: [
|
||||
expect.objectContaining({
|
||||
platformMessageId: "msg-1",
|
||||
kind: "text",
|
||||
threadId: "thread-1",
|
||||
replyToId: "parent-1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an outbound receipt instead of rebuilding it", async () => {
|
||||
const receipt: MessageReceipt = {
|
||||
primaryPlatformMessageId: "receipt-1",
|
||||
platformMessageIds: ["receipt-1", "receipt-2"],
|
||||
parts: [
|
||||
{ platformMessageId: "receipt-1", kind: "media", index: 0 },
|
||||
{ platformMessageId: "receipt-2", kind: "media", index: 1 },
|
||||
],
|
||||
sentAt: 123,
|
||||
};
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
outbound: {
|
||||
deliveryCapabilities: { durableFinal: { media: true } },
|
||||
sendMedia: vi.fn(async () => ({ channel: "demo", messageId: "legacy-id", receipt })),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.send?.media?.({
|
||||
cfg,
|
||||
to: "room-1",
|
||||
text: "caption",
|
||||
mediaUrl: "file:///tmp/a.png",
|
||||
}),
|
||||
).resolves.toEqual({ messageId: "legacy-id", receipt });
|
||||
});
|
||||
|
||||
it("wraps rich payload sends and infers the receipt part kind", async () => {
|
||||
const sendPayload = vi.fn(async () => ({ channel: "demo", messageId: "card-1" }));
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
capabilities: { payload: true, batch: true },
|
||||
outbound: { sendPayload },
|
||||
});
|
||||
|
||||
const result = await adapter.send?.payload?.({
|
||||
cfg,
|
||||
to: "room-1",
|
||||
text: "",
|
||||
payload: {
|
||||
presentation: { blocks: [{ type: "text", text: "ready" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.durableFinal?.capabilities).toEqual({ payload: true, batch: true });
|
||||
expect(sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
presentation: { blocks: [{ type: "text", text: "ready" }] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result?.receipt.parts[0]).toEqual(
|
||||
expect.objectContaining({ platformMessageId: "card-1", kind: "card" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes only send methods backed by outbound handlers", () => {
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
outbound: {
|
||||
sendText: vi.fn(async () => ({ messageId: "msg-1" })),
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.send?.text).toEqual(expect.any(Function));
|
||||
expect(adapter.send?.media).toBeUndefined();
|
||||
expect(adapter.send?.payload).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults outbound-derived adapters to plugin-owned receive acknowledgements", () => {
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
outbound: {
|
||||
sendText: vi.fn(async () => ({ messageId: "msg-1" })),
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.receive).toEqual({
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves declared live and receive lifecycle metadata", () => {
|
||||
const adapter = createChannelMessageAdapterFromOutbound({
|
||||
outbound: {},
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
},
|
||||
},
|
||||
receive: {
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.live).toEqual({
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
},
|
||||
});
|
||||
expect(adapter.receive).toEqual({
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
});
|
||||
});
|
||||
});
|
||||
148
src/channels/message/outbound-bridge.ts
Normal file
148
src/channels/message/outbound-bridge.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createMessageReceiptFromOutboundResults } from "./receipt.js";
|
||||
import type {
|
||||
ChannelMessageAdapterShape,
|
||||
ChannelMessageLiveAdapterShape,
|
||||
ChannelMessageReceiveAdapterShape,
|
||||
ChannelMessageSendMediaContext,
|
||||
ChannelMessageSendPayloadContext,
|
||||
ChannelMessageSendResult,
|
||||
ChannelMessageSendTextContext,
|
||||
DurableFinalDeliveryRequirementMap,
|
||||
MessageReceipt,
|
||||
MessageReceiptPartKind,
|
||||
MessageReceiptSourceResult,
|
||||
} from "./types.js";
|
||||
|
||||
const defaultManualReceiveAdapter = {
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
} as const satisfies ChannelMessageReceiveAdapterShape;
|
||||
|
||||
export type ChannelMessageOutboundBridgeResult = MessageReceiptSourceResult & {
|
||||
receipt?: MessageReceipt;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
|
||||
deliveryCapabilities?: {
|
||||
durableFinal?: DurableFinalDeliveryRequirementMap;
|
||||
};
|
||||
sendText?: (
|
||||
ctx: ChannelMessageSendTextContext<TConfig>,
|
||||
) => Promise<ChannelMessageOutboundBridgeResult>;
|
||||
sendMedia?: (
|
||||
ctx: ChannelMessageSendMediaContext<TConfig>,
|
||||
) => Promise<ChannelMessageOutboundBridgeResult>;
|
||||
sendPayload?: (
|
||||
ctx: ChannelMessageSendPayloadContext<TConfig>,
|
||||
) => Promise<ChannelMessageOutboundBridgeResult>;
|
||||
};
|
||||
|
||||
export type CreateChannelMessageAdapterFromOutboundParams<TConfig = unknown> = {
|
||||
id?: string;
|
||||
outbound: ChannelMessageOutboundBridgeAdapter<TConfig>;
|
||||
capabilities?: DurableFinalDeliveryRequirementMap;
|
||||
live?: ChannelMessageLiveAdapterShape;
|
||||
receive?: ChannelMessageReceiveAdapterShape;
|
||||
};
|
||||
|
||||
function resolveResultMessageId(result: ChannelMessageOutboundBridgeResult): string | undefined {
|
||||
return (
|
||||
result.messageId ??
|
||||
result.receipt?.primaryPlatformMessageId ??
|
||||
result.receipt?.platformMessageIds[0] ??
|
||||
result.chatId ??
|
||||
result.channelId ??
|
||||
result.roomId ??
|
||||
result.conversationId ??
|
||||
result.toJid ??
|
||||
result.pollId
|
||||
);
|
||||
}
|
||||
|
||||
function toMessageSendResult(
|
||||
result: ChannelMessageOutboundBridgeResult,
|
||||
params: {
|
||||
kind: MessageReceiptPartKind;
|
||||
threadId?: string | number | null;
|
||||
replyToId?: string | null;
|
||||
},
|
||||
): ChannelMessageSendResult {
|
||||
const receipt =
|
||||
result.receipt ??
|
||||
createMessageReceiptFromOutboundResults({
|
||||
results: [result],
|
||||
kind: params.kind,
|
||||
threadId: params.threadId == null ? undefined : String(params.threadId),
|
||||
replyToId: params.replyToId ?? undefined,
|
||||
});
|
||||
return {
|
||||
receipt,
|
||||
...(resolveResultMessageId({ ...result, receipt })
|
||||
? {
|
||||
messageId: resolveResultMessageId({ ...result, receipt }),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePayloadReceiptKind(
|
||||
ctx: ChannelMessageSendPayloadContext<unknown>,
|
||||
): MessageReceiptPartKind {
|
||||
if (
|
||||
ctx.payload.audioAsVoice &&
|
||||
(ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length)
|
||||
) {
|
||||
return "voice";
|
||||
}
|
||||
if (ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length) {
|
||||
return "media";
|
||||
}
|
||||
if (ctx.payload.text?.trim() || ctx.text.trim()) {
|
||||
return "text";
|
||||
}
|
||||
if (ctx.payload.presentation?.blocks?.length || ctx.payload.interactive) {
|
||||
return "card";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function createChannelMessageAdapterFromOutbound<TConfig = unknown>(
|
||||
params: CreateChannelMessageAdapterFromOutboundParams<TConfig>,
|
||||
): ChannelMessageAdapterShape<TConfig> {
|
||||
const send: NonNullable<ChannelMessageAdapterShape<TConfig>["send"]> = {};
|
||||
if (params.outbound.sendText) {
|
||||
send.text = async (ctx) =>
|
||||
toMessageSendResult(await params.outbound.sendText!(ctx), {
|
||||
kind: "text",
|
||||
threadId: ctx.threadId,
|
||||
replyToId: ctx.replyToId,
|
||||
});
|
||||
}
|
||||
if (params.outbound.sendMedia) {
|
||||
send.media = async (ctx) =>
|
||||
toMessageSendResult(await params.outbound.sendMedia!(ctx), {
|
||||
kind: ctx.audioAsVoice ? "voice" : "media",
|
||||
threadId: ctx.threadId,
|
||||
replyToId: ctx.replyToId,
|
||||
});
|
||||
}
|
||||
if (params.outbound.sendPayload) {
|
||||
send.payload = async (ctx) =>
|
||||
toMessageSendResult(await params.outbound.sendPayload!(ctx), {
|
||||
kind: resolvePayloadReceiptKind(ctx as ChannelMessageSendPayloadContext<unknown>),
|
||||
threadId: ctx.threadId,
|
||||
replyToId: ctx.replyToId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...(params.id ? { id: params.id } : {}),
|
||||
durableFinal: {
|
||||
capabilities: params.capabilities ?? params.outbound.deliveryCapabilities?.durableFinal,
|
||||
},
|
||||
send,
|
||||
...(params.live ? { live: params.live } : {}),
|
||||
receive: params.receive ?? defaultManualReceiveAdapter,
|
||||
};
|
||||
}
|
||||
91
src/channels/message/receipt.test.ts
Normal file
91
src/channels/message/receipt.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
listMessageReceiptPlatformIds,
|
||||
resolveMessageReceiptPrimaryId,
|
||||
} from "./receipt.js";
|
||||
|
||||
describe("createMessageReceiptFromOutboundResults", () => {
|
||||
it("builds a multi-part receipt from outbound delivery results", () => {
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{ channel: "telegram", messageId: "m1" },
|
||||
{ channel: "telegram", messageId: "m2" },
|
||||
],
|
||||
kind: "text",
|
||||
threadId: "topic-1",
|
||||
replyToId: "reply-1",
|
||||
sentAt: 123,
|
||||
});
|
||||
|
||||
expect(receipt).toEqual(
|
||||
expect.objectContaining({
|
||||
primaryPlatformMessageId: "m1",
|
||||
platformMessageIds: ["m1", "m2"],
|
||||
threadId: "topic-1",
|
||||
replyToId: "reply-1",
|
||||
sentAt: 123,
|
||||
}),
|
||||
);
|
||||
expect(receipt.parts).toEqual([
|
||||
expect.objectContaining({ platformMessageId: "m1", kind: "text", index: 0 }),
|
||||
expect.objectContaining({ platformMessageId: "m2", kind: "text", index: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses alternate platform ids when messageId is unavailable", () => {
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "whatsapp", messageId: "", toJid: "jid-1" }],
|
||||
sentAt: 123,
|
||||
});
|
||||
|
||||
expect(receipt.primaryPlatformMessageId).toBe("jid-1");
|
||||
expect(receipt.platformMessageIds).toEqual(["jid-1"]);
|
||||
});
|
||||
|
||||
it("preserves nested platform receipts before falling back to delivery ids", () => {
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{
|
||||
channel: "telegram",
|
||||
messageId: "top-level-ignored",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "platform-1",
|
||||
platformMessageIds: ["platform-1", "platform-2"],
|
||||
parts: [
|
||||
{ platformMessageId: "platform-1", kind: "text", index: 0 },
|
||||
{ platformMessageId: "platform-2", kind: "media", index: 1 },
|
||||
],
|
||||
threadId: "native-thread",
|
||||
sentAt: 123,
|
||||
},
|
||||
},
|
||||
{ channel: "telegram", messageId: "fallback-1" },
|
||||
],
|
||||
kind: "text",
|
||||
sentAt: 456,
|
||||
});
|
||||
|
||||
expect(receipt.primaryPlatformMessageId).toBe("platform-1");
|
||||
expect(receipt.platformMessageIds).toEqual(["platform-1", "platform-2", "fallback-1"]);
|
||||
expect(receipt.parts).toEqual([
|
||||
expect.objectContaining({ platformMessageId: "platform-1", kind: "text", index: 0 }),
|
||||
expect.objectContaining({ platformMessageId: "platform-2", kind: "media", index: 1 }),
|
||||
expect.objectContaining({ platformMessageId: "fallback-1", kind: "text", index: 1 }),
|
||||
]);
|
||||
expect(receipt.threadId).toBe("native-thread");
|
||||
expect(receipt.sentAt).toBe(456);
|
||||
});
|
||||
|
||||
it("normalizes receipt ids for compatibility edges", () => {
|
||||
const receipt = {
|
||||
primaryPlatformMessageId: " ",
|
||||
platformMessageIds: [" m1 ", "", "m1", "m2"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
expect(listMessageReceiptPlatformIds(receipt)).toEqual(["m1", "m2"]);
|
||||
expect(resolveMessageReceiptPrimaryId(receipt)).toBe("m1");
|
||||
});
|
||||
});
|
||||
122
src/channels/message/receipt.ts
Normal file
122
src/channels/message/receipt.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
MessageReceipt,
|
||||
MessageReceiptPartKind,
|
||||
MessageReceiptSourceResult,
|
||||
} from "./types.js";
|
||||
|
||||
type MessageReceiptInputResult = MessageReceiptSourceResult & {
|
||||
receipt?: MessageReceipt;
|
||||
};
|
||||
|
||||
function resolveReceiptMessageId(result: MessageReceiptInputResult): string | undefined {
|
||||
return (
|
||||
result.messageId ||
|
||||
result.chatId ||
|
||||
result.channelId ||
|
||||
result.roomId ||
|
||||
result.conversationId ||
|
||||
result.toJid ||
|
||||
result.pollId
|
||||
);
|
||||
}
|
||||
|
||||
function hasNestedReceiptData(receipt: MessageReceipt | undefined): receipt is MessageReceipt {
|
||||
return Boolean(
|
||||
receipt &&
|
||||
(receipt.parts.length > 0 ||
|
||||
receipt.platformMessageIds.length > 0 ||
|
||||
receipt.primaryPlatformMessageId),
|
||||
);
|
||||
}
|
||||
|
||||
function appendUnique(values: string[], value: string | undefined): void {
|
||||
const normalized = value?.trim();
|
||||
if (normalized && !values.includes(normalized)) {
|
||||
values.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMessageReceiptFromOutboundResults(params: {
|
||||
results: readonly MessageReceiptInputResult[];
|
||||
kind?: MessageReceiptPartKind;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
sentAt?: number;
|
||||
}): MessageReceipt {
|
||||
const parts = params.results.flatMap((result, resultIndex) => {
|
||||
if (hasNestedReceiptData(result.receipt)) {
|
||||
return result.receipt.parts.length > 0
|
||||
? result.receipt.parts.map((part, partIndex) => ({
|
||||
...part,
|
||||
index: part.index ?? partIndex,
|
||||
...(part.threadId || !params.threadId ? {} : { threadId: params.threadId }),
|
||||
...(part.replyToId || !params.replyToId ? {} : { replyToId: params.replyToId }),
|
||||
}))
|
||||
: result.receipt.platformMessageIds.map((platformMessageId, partIndex) => ({
|
||||
platformMessageId,
|
||||
kind: params.kind ?? "unknown",
|
||||
index: partIndex,
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
}));
|
||||
}
|
||||
const platformMessageId = resolveReceiptMessageId(result);
|
||||
if (!platformMessageId) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
platformMessageId,
|
||||
kind: params.kind ?? "unknown",
|
||||
index: resultIndex,
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
raw: result,
|
||||
},
|
||||
];
|
||||
});
|
||||
const platformMessageIds: string[] = [];
|
||||
for (const result of params.results) {
|
||||
if (hasNestedReceiptData(result.receipt)) {
|
||||
appendUnique(platformMessageIds, result.receipt.primaryPlatformMessageId);
|
||||
for (const platformMessageId of result.receipt.platformMessageIds) {
|
||||
appendUnique(platformMessageIds, platformMessageId);
|
||||
}
|
||||
for (const part of result.receipt.parts) {
|
||||
appendUnique(platformMessageIds, part.platformMessageId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
appendUnique(platformMessageIds, resolveReceiptMessageId(result));
|
||||
}
|
||||
const firstNestedReceipt = params.results.find((result) =>
|
||||
hasNestedReceiptData(result.receipt),
|
||||
)?.receipt;
|
||||
return {
|
||||
...(platformMessageIds[0] ? { primaryPlatformMessageId: platformMessageIds[0] } : {}),
|
||||
platformMessageIds,
|
||||
parts,
|
||||
...((params.threadId ?? firstNestedReceipt?.threadId)
|
||||
? { threadId: params.threadId ?? firstNestedReceipt?.threadId }
|
||||
: {}),
|
||||
...((params.replyToId ?? firstNestedReceipt?.replyToId)
|
||||
? { replyToId: params.replyToId ?? firstNestedReceipt?.replyToId }
|
||||
: {}),
|
||||
sentAt: params.sentAt ?? firstNestedReceipt?.sentAt ?? Date.now(),
|
||||
raw: params.results,
|
||||
};
|
||||
}
|
||||
|
||||
export function listMessageReceiptPlatformIds(receipt: MessageReceipt): string[] {
|
||||
return Array.from(
|
||||
new Set(receipt.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMessageReceiptPrimaryId(receipt: MessageReceipt): string | undefined {
|
||||
const primary = receipt.primaryPlatformMessageId?.trim();
|
||||
if (primary) {
|
||||
return primary;
|
||||
}
|
||||
return listMessageReceiptPlatformIds(receipt)[0];
|
||||
}
|
||||
85
src/channels/message/receive.ts
Normal file
85
src/channels/message/receive.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { ChannelMessageReceiveAckPolicy } from "./types.js";
|
||||
|
||||
export type MessageAckPolicy = ChannelMessageReceiveAckPolicy;
|
||||
|
||||
export type MessageAckStage = "receive_record" | "agent_dispatch" | "durable_send" | "manual";
|
||||
|
||||
export type MessageAckState = "pending" | "acked" | "nacked";
|
||||
|
||||
export type MessageReceiveContext<TMessage = unknown> = {
|
||||
id: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
message: TMessage;
|
||||
ackPolicy: MessageAckPolicy;
|
||||
ackState: MessageAckState;
|
||||
ackedAt?: number;
|
||||
nackErrorMessage?: string;
|
||||
receivedAt: number;
|
||||
signal: AbortSignal;
|
||||
shouldAckAfter(stage: MessageAckStage): boolean;
|
||||
ack(): Promise<void>;
|
||||
nack(error: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
export function shouldAckMessageAfterStage(
|
||||
policy: MessageAckPolicy,
|
||||
stage: MessageAckStage,
|
||||
): boolean {
|
||||
switch (policy) {
|
||||
case "after_receive_record":
|
||||
return stage === "receive_record";
|
||||
case "after_agent_dispatch":
|
||||
return stage === "agent_dispatch";
|
||||
case "after_durable_send":
|
||||
return stage === "durable_send";
|
||||
case "manual":
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeAckErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createMessageReceiveContext<TMessage>(params: {
|
||||
id: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
message: TMessage;
|
||||
ackPolicy?: MessageAckPolicy;
|
||||
receivedAt?: number;
|
||||
signal?: AbortSignal;
|
||||
onAck?: () => Promise<void> | void;
|
||||
onNack?: (error: unknown) => Promise<void> | void;
|
||||
}): MessageReceiveContext<TMessage> {
|
||||
const ctx: MessageReceiveContext<TMessage> = {
|
||||
id: params.id,
|
||||
channel: params.channel,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
message: params.message,
|
||||
ackPolicy: params.ackPolicy ?? "after_receive_record",
|
||||
ackState: "pending",
|
||||
receivedAt: params.receivedAt ?? Date.now(),
|
||||
signal: params.signal ?? neverAbortedSignal,
|
||||
shouldAckAfter: (stage) => shouldAckMessageAfterStage(ctx.ackPolicy, stage),
|
||||
ack: async () => {
|
||||
if (ctx.ackState === "acked") {
|
||||
return;
|
||||
}
|
||||
await params.onAck?.();
|
||||
ctx.ackState = "acked";
|
||||
ctx.ackedAt = Date.now();
|
||||
delete ctx.nackErrorMessage;
|
||||
},
|
||||
nack: async (error) => {
|
||||
await params.onNack?.(error);
|
||||
ctx.ackState = "nacked";
|
||||
ctx.nackErrorMessage = normalizeAckErrorMessage(error);
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
93
src/channels/message/rendered-batch.ts
Normal file
93
src/channels/message/rendered-batch.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import type {
|
||||
RenderedMessageBatch,
|
||||
RenderedMessageBatchPlan,
|
||||
RenderedMessageBatchPlanItem,
|
||||
RenderedMessageBatchPlanKind,
|
||||
} from "./types.js";
|
||||
|
||||
function countMedia(payload: ReplyPayload): number {
|
||||
return (payload.mediaUrls?.filter(Boolean).length ?? 0) + (payload.mediaUrl ? 1 : 0);
|
||||
}
|
||||
|
||||
function collectMediaUrls(payload: ReplyPayload): string[] {
|
||||
return [payload.mediaUrl, ...(payload.mediaUrls ?? [])]
|
||||
.map((url) => url?.trim())
|
||||
.filter((url): url is string => Boolean(url));
|
||||
}
|
||||
|
||||
function createRenderedMessageBatchPlanItem(
|
||||
payload: ReplyPayload,
|
||||
index: number,
|
||||
): RenderedMessageBatchPlanItem {
|
||||
const text = payload.text?.trim();
|
||||
const mediaUrls = collectMediaUrls(payload);
|
||||
const presentationBlockCount = payload.presentation?.blocks?.length ?? 0;
|
||||
const kinds: RenderedMessageBatchPlanKind[] = [];
|
||||
if (text) {
|
||||
kinds.push("text");
|
||||
}
|
||||
if (mediaUrls.length > 0) {
|
||||
kinds.push(payload.audioAsVoice ? "voice" : "media");
|
||||
}
|
||||
if (presentationBlockCount > 0) {
|
||||
kinds.push("presentation");
|
||||
}
|
||||
if (payload.interactive) {
|
||||
kinds.push("interactive");
|
||||
}
|
||||
if (payload.channelData) {
|
||||
kinds.push("channelData");
|
||||
}
|
||||
return {
|
||||
index,
|
||||
kinds: kinds.length > 0 ? kinds : ["empty"],
|
||||
...(text ? { text } : {}),
|
||||
mediaUrls,
|
||||
...(payload.audioAsVoice && mediaUrls.length > 0 ? { audioAsVoice: true } : {}),
|
||||
...(presentationBlockCount > 0 ? { presentationBlockCount } : {}),
|
||||
...(payload.interactive ? { hasInteractive: true } : {}),
|
||||
...(payload.channelData ? { hasChannelData: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRenderedMessageBatchPlan(
|
||||
payloads: readonly ReplyPayload[],
|
||||
): RenderedMessageBatchPlan {
|
||||
const items = payloads.map(createRenderedMessageBatchPlanItem);
|
||||
return payloads.reduce<RenderedMessageBatchPlan>(
|
||||
(plan, payload) => {
|
||||
const text = payload.text?.trim();
|
||||
const mediaCount = countMedia(payload);
|
||||
return {
|
||||
payloadCount: plan.payloadCount + 1,
|
||||
textCount: plan.textCount + (text ? 1 : 0),
|
||||
mediaCount: plan.mediaCount + mediaCount,
|
||||
voiceCount: plan.voiceCount + (payload.audioAsVoice && mediaCount > 0 ? 1 : 0),
|
||||
presentationCount: plan.presentationCount + (payload.presentation?.blocks?.length ? 1 : 0),
|
||||
interactiveCount: plan.interactiveCount + (payload.interactive ? 1 : 0),
|
||||
channelDataCount: plan.channelDataCount + (payload.channelData ? 1 : 0),
|
||||
items: plan.items,
|
||||
};
|
||||
},
|
||||
{
|
||||
payloadCount: 0,
|
||||
textCount: 0,
|
||||
mediaCount: 0,
|
||||
voiceCount: 0,
|
||||
presentationCount: 0,
|
||||
interactiveCount: 0,
|
||||
channelDataCount: 0,
|
||||
items,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createRenderedMessageBatch(
|
||||
payloads: ReplyPayload[],
|
||||
): RenderedMessageBatch<ReplyPayload> {
|
||||
return {
|
||||
payloads,
|
||||
plan: createRenderedMessageBatchPlan(payloads),
|
||||
};
|
||||
}
|
||||
91
src/channels/message/reply-pipeline.ts
Normal file
91
src/channels/message/reply-pipeline.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import {
|
||||
resolveSourceReplyDeliveryMode,
|
||||
type SourceReplyDeliveryModeContext,
|
||||
} from "../../auto-reply/reply/source-reply-delivery-mode.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../plugins/index.js";
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
type ReplyPrefixContextBundle,
|
||||
type ReplyPrefixOptions,
|
||||
} from "../reply-prefix.js";
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
type CreateTypingCallbacksParams,
|
||||
type TypingCallbacks,
|
||||
} from "../typing.js";
|
||||
|
||||
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
|
||||
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
|
||||
export type { CreateTypingCallbacksParams, TypingCallbacks };
|
||||
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
|
||||
export type { SourceReplyDeliveryMode };
|
||||
|
||||
export function resolveChannelSourceReplyDeliveryMode(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: SourceReplyDeliveryModeContext;
|
||||
requested?: SourceReplyDeliveryMode;
|
||||
messageToolAvailable?: boolean;
|
||||
}): SourceReplyDeliveryMode {
|
||||
return resolveSourceReplyDeliveryMode(params);
|
||||
}
|
||||
|
||||
export type ChannelReplyPipeline = ReplyPrefixOptions & {
|
||||
typingCallbacks?: TypingCallbacks;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
};
|
||||
|
||||
export type CreateChannelReplyPipelineParams = {
|
||||
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
|
||||
agentId: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
typing?: CreateTypingCallbacksParams;
|
||||
typingCallbacks?: TypingCallbacks;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
};
|
||||
|
||||
export function createChannelReplyPipeline(
|
||||
params: CreateChannelReplyPipelineParams,
|
||||
): ChannelReplyPipeline {
|
||||
const channelId = params.channel
|
||||
? (normalizeChannelId(params.channel) ?? params.channel)
|
||||
: undefined;
|
||||
let plugin: ReturnType<typeof getChannelPlugin> | undefined;
|
||||
let pluginTransformResolved = false;
|
||||
const resolvePluginTransform = () => {
|
||||
if (pluginTransformResolved) {
|
||||
return plugin?.messaging?.transformReplyPayload;
|
||||
}
|
||||
pluginTransformResolved = true;
|
||||
plugin = channelId ? getChannelPlugin(channelId) : undefined;
|
||||
return plugin?.messaging?.transformReplyPayload;
|
||||
};
|
||||
const transformReplyPayload = params.transformReplyPayload
|
||||
? params.transformReplyPayload
|
||||
: channelId
|
||||
? (payload: ReplyPayload) =>
|
||||
resolvePluginTransform()?.({
|
||||
payload,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}) ?? payload
|
||||
: undefined;
|
||||
return {
|
||||
...createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
}),
|
||||
...(transformReplyPayload ? { transformReplyPayload } : {}),
|
||||
...(params.typingCallbacks
|
||||
? { typingCallbacks: params.typingCallbacks }
|
||||
: params.typing
|
||||
? { typingCallbacks: createTypingCallbacks(params.typing) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
7
src/channels/message/runtime.ts
Normal file
7
src/channels/message/runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js";
|
||||
export type {
|
||||
DurableMessageBatchSendParams,
|
||||
DurableMessageBatchSendResult,
|
||||
DurableMessageSendContext,
|
||||
DurableMessageSendContextParams,
|
||||
} from "./send.js";
|
||||
451
src/channels/message/send.test.ts
Normal file
451
src/channels/message/send.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { OutboundDeliveryIntent } from "../../infra/outbound/deliver.js";
|
||||
|
||||
const deliverOutboundPayloads = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads,
|
||||
}));
|
||||
|
||||
import { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js";
|
||||
|
||||
type DeliveryIntentCallbackParams = {
|
||||
onDeliveryIntent?: (intent: OutboundDeliveryIntent) => void;
|
||||
};
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
describe("withDurableMessageSendContext", () => {
|
||||
it("renders and sends through a durable send context", async () => {
|
||||
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
|
||||
params.onDeliveryIntent?.({
|
||||
id: "intent-1",
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
queuePolicy: "required",
|
||||
});
|
||||
return [{ channel: "telegram", messageId: "msg-1" }];
|
||||
});
|
||||
|
||||
const result = await withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
threadId: 42,
|
||||
replyToId: "reply-1",
|
||||
},
|
||||
async (ctx) => {
|
||||
expect(ctx).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "telegram:chat-1",
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
durability: "required",
|
||||
attempt: 1,
|
||||
}),
|
||||
);
|
||||
const rendered = await ctx.render();
|
||||
expect(rendered).toEqual({
|
||||
payloads: [{ text: "hello" }],
|
||||
plan: expect.objectContaining({
|
||||
payloadCount: 1,
|
||||
textCount: 1,
|
||||
mediaCount: 0,
|
||||
items: [{ index: 0, kinds: ["text"] as const, text: "hello", mediaUrls: [] }],
|
||||
}),
|
||||
});
|
||||
const send = await ctx.send(rendered);
|
||||
expect(ctx.intent).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "intent-1",
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
durability: "required",
|
||||
renderedBatch: rendered,
|
||||
}),
|
||||
);
|
||||
return send;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "sent",
|
||||
deliveryIntent: expect.objectContaining({ id: "intent-1" }),
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["msg-1"],
|
||||
threadId: "42",
|
||||
replyToId: "reply-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queuePolicy: "required",
|
||||
payloads: [{ text: "hello" }],
|
||||
threadId: 42,
|
||||
replyToId: "reply-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a replayable rendered batch plan on the durable intent", async () => {
|
||||
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
|
||||
params.onDeliveryIntent?.({
|
||||
id: "intent-media",
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
queuePolicy: "required",
|
||||
});
|
||||
return [{ channel: "telegram", messageId: "media-1" }];
|
||||
});
|
||||
let intent: unknown;
|
||||
|
||||
await withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"],
|
||||
audioAsVoice: true,
|
||||
presentation: { blocks: [{ type: "text", text: "card" }] },
|
||||
interactive: { blocks: [{ type: "buttons", buttons: [{ label: "OK" }] }] },
|
||||
channelData: { native: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
async (ctx) => {
|
||||
const rendered = await ctx.render();
|
||||
await ctx.send(rendered);
|
||||
intent = ctx.intent;
|
||||
},
|
||||
);
|
||||
|
||||
expect(intent).toEqual(
|
||||
expect.objectContaining({
|
||||
renderedBatch: expect.objectContaining({
|
||||
plan: {
|
||||
payloadCount: 1,
|
||||
textCount: 1,
|
||||
mediaCount: 2,
|
||||
voiceCount: 1,
|
||||
presentationCount: 1,
|
||||
interactiveCount: 1,
|
||||
channelDataCount: 1,
|
||||
items: [
|
||||
{
|
||||
index: 0,
|
||||
kinds: ["text", "voice", "presentation", "interactive", "channelData"] as const,
|
||||
text: "caption",
|
||||
mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"],
|
||||
audioAsVoice: true,
|
||||
presentationBlockCount: 1,
|
||||
hasInteractive: true,
|
||||
hasChannelData: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the durable send context signal to outbound delivery", async () => {
|
||||
const abortController = new AbortController();
|
||||
deliverOutboundPayloads.mockImplementationOnce(
|
||||
async (params: DeliveryIntentCallbackParams & { abortSignal?: AbortSignal }) => {
|
||||
expect(params.abortSignal).toBe(abortController.signal);
|
||||
return [{ channel: "telegram", messageId: "msg-1" }];
|
||||
},
|
||||
);
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "sent",
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["msg-1"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(deliverOutboundPayloads).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
abortSignal: abortController.signal,
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps best-effort durability to best-effort queue policy", async () => {
|
||||
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
|
||||
params.onDeliveryIntent?.({
|
||||
id: "intent-best-effort",
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
queuePolicy: "best_effort",
|
||||
});
|
||||
return [{ channel: "telegram", messageId: "msg-1" }];
|
||||
});
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
durability: "best_effort",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "sent",
|
||||
deliveryIntent: expect.objectContaining({ id: "intent-best-effort" }),
|
||||
}),
|
||||
);
|
||||
expect(deliverOutboundPayloads).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
queuePolicy: "best_effort",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves adapter-provided multipart receipts in durable sends", async () => {
|
||||
deliverOutboundPayloads.mockResolvedValueOnce([
|
||||
{
|
||||
channel: "telegram",
|
||||
messageId: "top-level-ignored",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "platform-1",
|
||||
platformMessageIds: ["platform-1", "platform-2"],
|
||||
parts: [
|
||||
{ platformMessageId: "platform-1", kind: "text", index: 0 },
|
||||
{ platformMessageId: "platform-2", kind: "media", index: 1 },
|
||||
],
|
||||
sentAt: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "sent",
|
||||
receipt: expect.objectContaining({
|
||||
primaryPlatformMessageId: "platform-1",
|
||||
platformMessageIds: ["platform-1", "platform-2"],
|
||||
parts: [
|
||||
expect.objectContaining({ platformMessageId: "platform-1", kind: "text" }),
|
||||
expect.objectContaining({ platformMessageId: "platform-2", kind: "media" }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports preview, edit, and delete send-context hooks", async () => {
|
||||
const receipt = {
|
||||
primaryPlatformMessageId: "preview-1",
|
||||
platformMessageIds: ["preview-1"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
const editedReceipt = {
|
||||
...receipt,
|
||||
primaryPlatformMessageId: "preview-1-edited",
|
||||
platformMessageIds: ["preview-1-edited"],
|
||||
};
|
||||
const onEditReceipt = vi.fn(async () => editedReceipt);
|
||||
const onDeleteReceipt = vi.fn(async () => undefined);
|
||||
|
||||
await withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "final" }],
|
||||
preview: {
|
||||
phase: "previewing",
|
||||
canFinalizeInPlace: true,
|
||||
receipt,
|
||||
},
|
||||
onEditReceipt,
|
||||
onDeleteReceipt,
|
||||
},
|
||||
async (ctx) => {
|
||||
const rendered = await ctx.render();
|
||||
const preview = await ctx.previewUpdate(rendered);
|
||||
expect(preview.lastRendered).toBe(rendered);
|
||||
|
||||
await expect(ctx.edit(receipt, rendered)).resolves.toBe(editedReceipt);
|
||||
await ctx.delete(editedReceipt);
|
||||
},
|
||||
);
|
||||
|
||||
expect(onEditReceipt).toHaveBeenCalledWith(
|
||||
receipt,
|
||||
expect.objectContaining({ payloads: [{ text: "final" }] }),
|
||||
);
|
||||
expect(onDeleteReceipt).toHaveBeenCalledWith(editedReceipt);
|
||||
});
|
||||
|
||||
it("fails explicit edit and delete operations without a live adapter", async () => {
|
||||
const receipt = {
|
||||
primaryPlatformMessageId: "preview-1",
|
||||
platformMessageIds: ["preview-1"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
await withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "final" }],
|
||||
},
|
||||
async (ctx) => {
|
||||
const rendered = await ctx.render();
|
||||
await expect(ctx.edit(receipt, rendered)).rejects.toThrow(
|
||||
"message send context edit is not configured",
|
||||
);
|
||||
await expect(ctx.delete(receipt)).rejects.toThrow(
|
||||
"message send context delete is not configured",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("treats no visible outbound result as a committed suppressed send", async () => {
|
||||
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
|
||||
params.onDeliveryIntent?.({
|
||||
id: "intent-2",
|
||||
channel: "whatsapp",
|
||||
to: "jid-1",
|
||||
queuePolicy: "required",
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const onCommitReceipt = vi.fn();
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "jid-1",
|
||||
payloads: [{ text: "hidden" }],
|
||||
onCommitReceipt,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "suppressed",
|
||||
reason: "no_visible_result",
|
||||
deliveryIntent: expect.objectContaining({ id: "intent-2" }),
|
||||
}),
|
||||
);
|
||||
expect(onCommitReceipt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
platformMessageIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the failure hook when send-context orchestration throws", async () => {
|
||||
const onSendFailure = vi.fn();
|
||||
const error = new Error("boom");
|
||||
|
||||
await expect(
|
||||
withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
onSendFailure,
|
||||
},
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(onSendFailure).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("preserves orchestration errors when the failure hook throws", async () => {
|
||||
const onSendFailure = vi.fn(async () => {
|
||||
throw new Error("cleanup failed");
|
||||
});
|
||||
const error = new Error("boom");
|
||||
|
||||
await expect(
|
||||
withDurableMessageSendContext(
|
||||
{
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
onSendFailure,
|
||||
},
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(onSendFailure).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("runs the failure hook when durable outbound delivery fails", async () => {
|
||||
const error = new Error("send failed");
|
||||
deliverOutboundPayloads.mockRejectedValueOnce(error);
|
||||
const onSendFailure = vi.fn();
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
onSendFailure,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "failed", error });
|
||||
expect(onSendFailure).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("preserves failed send results when the failure hook throws", async () => {
|
||||
const error = new Error("send failed");
|
||||
deliverOutboundPayloads.mockRejectedValueOnce(error);
|
||||
const onSendFailure = vi.fn(async () => {
|
||||
throw new Error("cleanup failed");
|
||||
});
|
||||
|
||||
const result = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
payloads: [{ text: "hello" }],
|
||||
onSendFailure,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "failed", error });
|
||||
expect(onSendFailure).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
223
src/channels/message/send.ts
Normal file
223
src/channels/message/send.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
|
||||
import {
|
||||
deliverOutboundPayloads,
|
||||
type DeliverOutboundPayloadsParams,
|
||||
type OutboundDeliveryIntent,
|
||||
} from "../../infra/outbound/deliver.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createLiveMessageState, markLiveMessagePreviewUpdated } from "./live.js";
|
||||
import { createMessageReceiptFromOutboundResults } from "./receipt.js";
|
||||
import { createRenderedMessageBatch } from "./rendered-batch.js";
|
||||
import type {
|
||||
DurableMessageSendIntent,
|
||||
LiveMessageState,
|
||||
MessageDurabilityPolicy,
|
||||
MessageReceipt,
|
||||
MessageSendContext,
|
||||
RenderedMessageBatch,
|
||||
} from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("channels/message/send");
|
||||
|
||||
export type DurableMessageBatchSendParams = Omit<
|
||||
DeliverOutboundPayloadsParams,
|
||||
"abortSignal" | "onDeliveryIntent" | "payloads" | "queuePolicy"
|
||||
> & {
|
||||
payloads: ReplyPayload[];
|
||||
attempt?: number;
|
||||
signal?: AbortSignal;
|
||||
/** @deprecated Use `signal`. */
|
||||
abortSignal?: AbortSignal;
|
||||
previousReceipt?: MessageReceipt;
|
||||
};
|
||||
|
||||
export type DurableMessageBatchSendResult =
|
||||
| {
|
||||
status: "sent";
|
||||
results: OutboundDeliveryResult[];
|
||||
receipt: MessageReceipt;
|
||||
deliveryIntent?: OutboundDeliveryIntent;
|
||||
}
|
||||
| {
|
||||
status: "suppressed";
|
||||
results: [];
|
||||
receipt: MessageReceipt;
|
||||
deliveryIntent?: OutboundDeliveryIntent;
|
||||
reason: "no_visible_result";
|
||||
}
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
function toDurableMessageIntent(
|
||||
intent: OutboundDeliveryIntent,
|
||||
renderedBatch: RenderedMessageBatch<ReplyPayload>,
|
||||
): DurableMessageSendIntent<ReplyPayload> {
|
||||
return {
|
||||
id: intent.id,
|
||||
channel: intent.channel,
|
||||
to: intent.to,
|
||||
...(intent.accountId ? { accountId: intent.accountId } : {}),
|
||||
durability: intent.queuePolicy === "required" ? "required" : "best_effort",
|
||||
renderedBatch,
|
||||
};
|
||||
}
|
||||
|
||||
export type DurableMessageSendContextParams = DurableMessageBatchSendParams & {
|
||||
durability?: Exclude<MessageDurabilityPolicy, "disabled">;
|
||||
preview?: LiveMessageState<ReplyPayload>;
|
||||
onPreviewUpdate?: (
|
||||
rendered: RenderedMessageBatch<ReplyPayload>,
|
||||
state: LiveMessageState<ReplyPayload>,
|
||||
) => Promise<LiveMessageState<ReplyPayload>> | LiveMessageState<ReplyPayload>;
|
||||
onEditReceipt?: (
|
||||
receipt: MessageReceipt,
|
||||
rendered: RenderedMessageBatch<ReplyPayload>,
|
||||
) => Promise<MessageReceipt> | MessageReceipt;
|
||||
onDeleteReceipt?: (receipt: MessageReceipt) => Promise<void> | void;
|
||||
onCommitReceipt?: (receipt: MessageReceipt) => Promise<void> | void;
|
||||
onSendFailure?: (error: unknown) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type DurableMessageSendContext = MessageSendContext<
|
||||
ReplyPayload,
|
||||
DurableMessageBatchSendResult
|
||||
>;
|
||||
|
||||
export async function withDurableMessageSendContext<T>(
|
||||
params: DurableMessageSendContextParams,
|
||||
run: (ctx: DurableMessageSendContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
let deliveryIntent: OutboundDeliveryIntent | undefined;
|
||||
const {
|
||||
attempt,
|
||||
durability,
|
||||
onDeleteReceipt,
|
||||
onEditReceipt,
|
||||
onCommitReceipt,
|
||||
onPreviewUpdate,
|
||||
onSendFailure,
|
||||
payloads,
|
||||
preview,
|
||||
previousReceipt,
|
||||
signal,
|
||||
abortSignal,
|
||||
...deliveryParams
|
||||
} = params;
|
||||
const effectiveSignal = signal ?? abortSignal;
|
||||
const queuePolicy = durability === "best_effort" ? "best_effort" : "required";
|
||||
let liveState = preview ?? createLiveMessageState<ReplyPayload>();
|
||||
const ctx: DurableMessageSendContext = {
|
||||
id: `${params.channel}:${params.to}`,
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
durability: durability ?? "required",
|
||||
attempt: attempt ?? 1,
|
||||
signal: effectiveSignal ?? neverAbortedSignal,
|
||||
...(previousReceipt ? { previousReceipt } : {}),
|
||||
preview: liveState,
|
||||
render: async (): Promise<RenderedMessageBatch<ReplyPayload>> =>
|
||||
createRenderedMessageBatch(payloads),
|
||||
previewUpdate: async (rendered): Promise<LiveMessageState<ReplyPayload>> => {
|
||||
liveState = onPreviewUpdate
|
||||
? await onPreviewUpdate(rendered, liveState)
|
||||
: markLiveMessagePreviewUpdated(liveState, rendered);
|
||||
ctx.preview = liveState;
|
||||
return liveState;
|
||||
},
|
||||
send: async (rendered): Promise<DurableMessageBatchSendResult> => {
|
||||
try {
|
||||
const results = await deliverOutboundPayloads({
|
||||
...deliveryParams,
|
||||
payloads: rendered.payloads,
|
||||
renderedBatchPlan: rendered.plan,
|
||||
queuePolicy,
|
||||
...(effectiveSignal ? { abortSignal: effectiveSignal } : {}),
|
||||
onDeliveryIntent: (intent) => {
|
||||
deliveryIntent = intent;
|
||||
ctx.intent = toDurableMessageIntent(intent, rendered);
|
||||
},
|
||||
});
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results,
|
||||
threadId: params.threadId == null ? undefined : String(params.threadId),
|
||||
replyToId: params.replyToId ?? undefined,
|
||||
});
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
status: "suppressed",
|
||||
results: [],
|
||||
receipt,
|
||||
...(deliveryIntent ? { deliveryIntent } : {}),
|
||||
reason: "no_visible_result",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "sent",
|
||||
results,
|
||||
receipt,
|
||||
...(deliveryIntent ? { deliveryIntent } : {}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { status: "failed", error };
|
||||
}
|
||||
},
|
||||
edit: async (receipt, rendered): Promise<MessageReceipt> => {
|
||||
if (!onEditReceipt) {
|
||||
throw new Error("message send context edit is not configured");
|
||||
}
|
||||
const editedReceipt = await onEditReceipt(receipt, rendered);
|
||||
liveState = {
|
||||
...liveState,
|
||||
receipt: editedReceipt,
|
||||
lastRendered: rendered,
|
||||
};
|
||||
ctx.preview = liveState;
|
||||
return editedReceipt;
|
||||
},
|
||||
delete: async (receipt) => {
|
||||
if (!onDeleteReceipt) {
|
||||
throw new Error("message send context delete is not configured");
|
||||
}
|
||||
await onDeleteReceipt(receipt);
|
||||
},
|
||||
commit: async (receipt) => {
|
||||
await onCommitReceipt?.(receipt);
|
||||
},
|
||||
fail: async (error) => {
|
||||
try {
|
||||
await onSendFailure?.(error);
|
||||
} catch (cleanupError: unknown) {
|
||||
log.warn(
|
||||
`message send failure cleanup failed; preserving original send error: ${formatErrorMessage(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await run(ctx);
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
await ctx.fail(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDurableMessageBatch(
|
||||
params: DurableMessageSendContextParams,
|
||||
): Promise<DurableMessageBatchSendResult> {
|
||||
return await withDurableMessageSendContext(params, async (ctx) => {
|
||||
const rendered = await ctx.render();
|
||||
const result = await ctx.send(rendered);
|
||||
if (result.status !== "failed") {
|
||||
await ctx.commit(result.receipt);
|
||||
} else {
|
||||
await ctx.fail(result.error);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
58
src/channels/message/state.ts
Normal file
58
src/channels/message/state.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { DurableMessageSendIntent, MessageReceipt } from "./types.js";
|
||||
|
||||
export type DurableMessageSendState =
|
||||
| "pending"
|
||||
| "sent"
|
||||
| "suppressed"
|
||||
| "failed"
|
||||
| "unknown_after_send";
|
||||
|
||||
export type DurableMessageStateRecord = {
|
||||
intent: DurableMessageSendIntent;
|
||||
state: DurableMessageSendState;
|
||||
receipt?: MessageReceipt;
|
||||
updatedAt: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function createDurableMessageStateRecord(params: {
|
||||
intent: DurableMessageSendIntent;
|
||||
state?: DurableMessageSendState;
|
||||
receipt?: MessageReceipt;
|
||||
updatedAt?: number;
|
||||
error?: unknown;
|
||||
}): DurableMessageStateRecord {
|
||||
return {
|
||||
intent: params.intent,
|
||||
state: params.state ?? (params.receipt ? "sent" : "pending"),
|
||||
...(params.receipt ? { receipt: params.receipt } : {}),
|
||||
updatedAt: params.updatedAt ?? Date.now(),
|
||||
...(params.error === undefined ? {} : { errorMessage: normalizeErrorMessage(params.error) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyDurableSendRecoveryState(params: {
|
||||
hasIntent: boolean;
|
||||
hasReceipt: boolean;
|
||||
platformSendMayHaveStarted: boolean;
|
||||
failed?: boolean;
|
||||
suppressed?: boolean;
|
||||
}): DurableMessageSendState {
|
||||
if (params.failed) {
|
||||
return "failed";
|
||||
}
|
||||
if (params.suppressed) {
|
||||
return "suppressed";
|
||||
}
|
||||
if (params.hasReceipt) {
|
||||
return "sent";
|
||||
}
|
||||
if (params.hasIntent && params.platformSendMayHaveStarted) {
|
||||
return "unknown_after_send";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
367
src/channels/message/types.ts
Normal file
367
src/channels/message/types.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js";
|
||||
import type { OutboundMediaAccess } from "../../media/load-options.js";
|
||||
|
||||
export type MessageDurabilityPolicy = "required" | "best_effort" | "disabled";
|
||||
|
||||
export const durableFinalDeliveryCapabilities = [
|
||||
"text",
|
||||
"media",
|
||||
"payload",
|
||||
"silent",
|
||||
"replyTo",
|
||||
"thread",
|
||||
"nativeQuote",
|
||||
"messageSendingHooks",
|
||||
"batch",
|
||||
"reconcileUnknownSend",
|
||||
"afterSendSuccess",
|
||||
"afterCommit",
|
||||
] as const;
|
||||
|
||||
export type DurableFinalDeliveryCapability = (typeof durableFinalDeliveryCapabilities)[number];
|
||||
|
||||
export type DurableFinalDeliveryRequirementMap = Partial<
|
||||
Record<DurableFinalDeliveryCapability, boolean>
|
||||
>;
|
||||
|
||||
export type DurableFinalDeliveryPayloadShape = {
|
||||
text?: string | null;
|
||||
replyToId?: string | null;
|
||||
mediaUrl?: string | null;
|
||||
mediaUrls?: readonly (string | null | undefined)[] | null;
|
||||
};
|
||||
|
||||
export type MessageReceiptSourceResult = {
|
||||
channel?: string;
|
||||
messageId?: string;
|
||||
chatId?: string;
|
||||
channelId?: string;
|
||||
roomId?: string;
|
||||
conversationId?: string;
|
||||
toJid?: string;
|
||||
pollId?: string;
|
||||
timestamp?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MessageReceiptPartKind = "text" | "media" | "voice" | "card" | "preview" | "unknown";
|
||||
|
||||
export type MessageReceiptPart = {
|
||||
platformMessageId: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
index: number;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
raw?: MessageReceiptSourceResult;
|
||||
};
|
||||
|
||||
export type MessageReceipt = {
|
||||
primaryPlatformMessageId?: string;
|
||||
platformMessageIds: string[];
|
||||
parts: MessageReceiptPart[];
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
editToken?: string;
|
||||
deleteToken?: string;
|
||||
sentAt: number;
|
||||
raw?: readonly MessageReceiptSourceResult[];
|
||||
};
|
||||
|
||||
export type RenderedMessageBatchPlanKind =
|
||||
| "text"
|
||||
| "media"
|
||||
| "voice"
|
||||
| "presentation"
|
||||
| "interactive"
|
||||
| "channelData"
|
||||
| "empty";
|
||||
|
||||
export type RenderedMessageBatchPlanItem = {
|
||||
index: number;
|
||||
kinds: readonly RenderedMessageBatchPlanKind[];
|
||||
text?: string;
|
||||
mediaUrls: readonly string[];
|
||||
audioAsVoice?: boolean;
|
||||
presentationBlockCount?: number;
|
||||
hasInteractive?: boolean;
|
||||
hasChannelData?: boolean;
|
||||
};
|
||||
|
||||
export type RenderedMessageBatchPlan = {
|
||||
payloadCount: number;
|
||||
textCount: number;
|
||||
mediaCount: number;
|
||||
voiceCount: number;
|
||||
presentationCount: number;
|
||||
interactiveCount: number;
|
||||
channelDataCount: number;
|
||||
items: readonly RenderedMessageBatchPlanItem[];
|
||||
};
|
||||
|
||||
export type RenderedMessageBatch<TPayload = unknown> = {
|
||||
payloads: TPayload[];
|
||||
plan: RenderedMessageBatchPlan;
|
||||
};
|
||||
|
||||
export type LiveMessagePhase = "idle" | "previewing" | "finalizing" | "finalized" | "cancelled";
|
||||
|
||||
export type LiveMessageState<TPayload = unknown> = {
|
||||
phase: LiveMessagePhase;
|
||||
canFinalizeInPlace: boolean;
|
||||
receipt?: MessageReceipt;
|
||||
lastRendered?: RenderedMessageBatch<TPayload>;
|
||||
};
|
||||
|
||||
export type MessageSendContext<TPayload = unknown, TSendResult = unknown> = {
|
||||
id: string;
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
durability: Exclude<MessageDurabilityPolicy, "disabled">;
|
||||
attempt: number;
|
||||
signal: AbortSignal;
|
||||
intent?: DurableMessageSendIntent;
|
||||
previousReceipt?: MessageReceipt;
|
||||
preview?: LiveMessageState<TPayload>;
|
||||
render(): Promise<RenderedMessageBatch<TPayload>>;
|
||||
previewUpdate(rendered: RenderedMessageBatch<TPayload>): Promise<LiveMessageState<TPayload>>;
|
||||
send(rendered: RenderedMessageBatch<TPayload>): Promise<TSendResult>;
|
||||
edit(receipt: MessageReceipt, rendered: RenderedMessageBatch<TPayload>): Promise<MessageReceipt>;
|
||||
delete(receipt: MessageReceipt): Promise<void>;
|
||||
commit(receipt: MessageReceipt): Promise<void>;
|
||||
fail(error: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendTextContext<TConfig = OpenClawConfig> = {
|
||||
cfg: TConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
deps?: OutboundSendDeps;
|
||||
replyToId?: string | null;
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
replyToMode?: ReplyToMode;
|
||||
threadId?: string | number | null;
|
||||
silent?: boolean;
|
||||
signal?: AbortSignal;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
};
|
||||
|
||||
export type ChannelMessageSendMediaContext<TConfig = OpenClawConfig> =
|
||||
ChannelMessageSendTextContext<TConfig> & {
|
||||
mediaUrl: string;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
audioAsVoice?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
forceDocument?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendPayloadContext<TConfig = OpenClawConfig> =
|
||||
ChannelMessageSendTextContext<TConfig> & {
|
||||
payload: ReplyPayload;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
audioAsVoice?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
forceDocument?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendResult = {
|
||||
receipt: MessageReceipt;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendAttemptKind = "text" | "media" | "payload";
|
||||
|
||||
export type ChannelMessageSendAttemptContext<TConfig = OpenClawConfig> =
|
||||
| (ChannelMessageSendTextContext<TConfig> & { kind: "text" })
|
||||
| (ChannelMessageSendMediaContext<TConfig> & { kind: "media" })
|
||||
| (ChannelMessageSendPayloadContext<TConfig> & { kind: "payload" });
|
||||
|
||||
export type ChannelMessageSendSuccessContext<
|
||||
TConfig = OpenClawConfig,
|
||||
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
|
||||
> = ChannelMessageSendAttemptContext<TConfig> & {
|
||||
result: TSendResult;
|
||||
attemptToken?: unknown;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendFailureContext<TConfig = OpenClawConfig> =
|
||||
ChannelMessageSendAttemptContext<TConfig> & {
|
||||
error: unknown;
|
||||
attemptToken?: unknown;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendCommitContext<
|
||||
TConfig = OpenClawConfig,
|
||||
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
|
||||
> = ChannelMessageSendSuccessContext<TConfig, TSendResult>;
|
||||
|
||||
export type ChannelMessageUnknownSendContext<TConfig = OpenClawConfig> = {
|
||||
cfg: TConfig;
|
||||
queueId: string;
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
enqueuedAt: number;
|
||||
retryCount: number;
|
||||
platformSendStartedAt?: number;
|
||||
payloads: readonly ReplyPayload[];
|
||||
renderedBatchPlan?: RenderedMessageBatchPlan;
|
||||
replyToId?: string | null;
|
||||
replyToMode?: ReplyToMode;
|
||||
threadId?: string | number | null;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelMessageUnknownSendReconciliationResult =
|
||||
| {
|
||||
status: "sent";
|
||||
receipt: MessageReceipt;
|
||||
messageId?: string;
|
||||
}
|
||||
| {
|
||||
status: "not_sent";
|
||||
}
|
||||
| {
|
||||
status: "unresolved";
|
||||
error?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendLifecycleAdapter<
|
||||
TConfig = OpenClawConfig,
|
||||
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
|
||||
> = {
|
||||
beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext<TConfig>) => unknown;
|
||||
afterSendSuccess?: (
|
||||
ctx: ChannelMessageSendSuccessContext<TConfig, TSendResult>,
|
||||
) => Promise<void> | void;
|
||||
afterSendFailure?: (ctx: ChannelMessageSendFailureContext<TConfig>) => Promise<void> | void;
|
||||
afterCommit?: (
|
||||
ctx: ChannelMessageSendCommitContext<TConfig, TSendResult>,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ChannelMessageSendAdapter<
|
||||
TConfig = OpenClawConfig,
|
||||
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
|
||||
> = {
|
||||
text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
|
||||
media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
|
||||
payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
|
||||
lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
|
||||
};
|
||||
|
||||
export type ChannelMessageDurableFinalAdapter = {
|
||||
capabilities?: DurableFinalDeliveryRequirementMap;
|
||||
reconcileUnknownSend?: (
|
||||
ctx: ChannelMessageUnknownSendContext,
|
||||
) =>
|
||||
| Promise<ChannelMessageUnknownSendReconciliationResult | null>
|
||||
| ChannelMessageUnknownSendReconciliationResult
|
||||
| null;
|
||||
};
|
||||
|
||||
export type ChannelMessageLiveCapability =
|
||||
| "draftPreview"
|
||||
| "previewFinalization"
|
||||
| "progressUpdates"
|
||||
| "nativeStreaming"
|
||||
| "quietFinalization";
|
||||
|
||||
export const channelMessageLiveCapabilities = [
|
||||
"draftPreview",
|
||||
"previewFinalization",
|
||||
"progressUpdates",
|
||||
"nativeStreaming",
|
||||
"quietFinalization",
|
||||
] as const satisfies readonly ChannelMessageLiveCapability[];
|
||||
|
||||
export const livePreviewFinalizerCapabilities = [
|
||||
"finalEdit",
|
||||
"normalFallback",
|
||||
"discardPending",
|
||||
"previewReceipt",
|
||||
"retainOnAmbiguousFailure",
|
||||
] as const;
|
||||
|
||||
export type LivePreviewFinalizerCapability = (typeof livePreviewFinalizerCapabilities)[number];
|
||||
|
||||
export type LivePreviewFinalizerCapabilityMap = Partial<
|
||||
Record<LivePreviewFinalizerCapability, boolean>
|
||||
>;
|
||||
|
||||
export type ChannelMessageLiveFinalizerAdapterShape = {
|
||||
capabilities?: LivePreviewFinalizerCapabilityMap;
|
||||
};
|
||||
|
||||
export type ChannelMessageLiveAdapterShape = {
|
||||
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
|
||||
finalizer?: ChannelMessageLiveFinalizerAdapterShape;
|
||||
};
|
||||
|
||||
export type ChannelMessageReceiveAckPolicy =
|
||||
| "after_receive_record"
|
||||
| "after_agent_dispatch"
|
||||
| "after_durable_send"
|
||||
| "manual";
|
||||
|
||||
export const channelMessageReceiveAckPolicies = [
|
||||
"after_receive_record",
|
||||
"after_agent_dispatch",
|
||||
"after_durable_send",
|
||||
"manual",
|
||||
] as const satisfies readonly ChannelMessageReceiveAckPolicy[];
|
||||
|
||||
export type ChannelMessageReceiveAdapterShape = {
|
||||
defaultAckPolicy?: ChannelMessageReceiveAckPolicy;
|
||||
supportedAckPolicies?: readonly ChannelMessageReceiveAckPolicy[];
|
||||
};
|
||||
|
||||
export type ChannelMessageAdapterShape<
|
||||
TConfig = OpenClawConfig,
|
||||
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
|
||||
> = {
|
||||
id?: string;
|
||||
durableFinal?: ChannelMessageDurableFinalAdapter;
|
||||
send?: ChannelMessageSendAdapter<TConfig, TSendResult>;
|
||||
live?: ChannelMessageLiveAdapterShape;
|
||||
receive?: ChannelMessageReceiveAdapterShape;
|
||||
};
|
||||
|
||||
export type ChannelMessageAdapter<
|
||||
TAdapter extends ChannelMessageAdapterShape = ChannelMessageAdapterShape,
|
||||
> = TAdapter;
|
||||
|
||||
export type DurableFinalRequirementExtras = DurableFinalDeliveryRequirementMap;
|
||||
|
||||
export type DeriveDurableFinalDeliveryRequirementsParams = {
|
||||
payload: DurableFinalDeliveryPayloadShape;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
silent?: boolean;
|
||||
messageSendingHooks?: boolean;
|
||||
payloadTransport?: boolean;
|
||||
batch?: boolean;
|
||||
reconcileUnknownSend?: boolean;
|
||||
afterSendSuccess?: boolean;
|
||||
afterCommit?: boolean;
|
||||
extraCapabilities?: DurableFinalRequirementExtras;
|
||||
};
|
||||
|
||||
export type DurableMessageSendIntent<TPayload = unknown> = {
|
||||
id: string;
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
durability: Exclude<MessageDurabilityPolicy, "disabled">;
|
||||
renderedBatch?: RenderedMessageBatch<TPayload>;
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
ChannelOutboundPayloadContext,
|
||||
ChannelOutboundPayloadHint,
|
||||
ChannelOutboundTargetRef,
|
||||
ChannelDeliveryCapabilities,
|
||||
} from "./outbound.types.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
|
||||
@@ -700,6 +700,14 @@ export type ChannelToolSend = {
|
||||
threadId?: string | null;
|
||||
};
|
||||
|
||||
export type ChannelMessagePreparedSendPayloadContext = {
|
||||
ctx: ChannelMessageActionContext;
|
||||
to: string;
|
||||
payload: ReplyPayload;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
/** Channel-owned action surface for the shared `message` tool. */
|
||||
export type ChannelMessageActionAdapter = {
|
||||
/**
|
||||
@@ -733,6 +741,14 @@ export type ChannelMessageActionAdapter = {
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
}) => boolean;
|
||||
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
|
||||
/**
|
||||
* Translate generic `message(action=send)` arguments into the payload core
|
||||
* should persist, retry, recover, and ack. Return null to keep the legacy
|
||||
* plugin-owned action path for sends that cannot be represented durably.
|
||||
*/
|
||||
prepareSendPayload?: (
|
||||
params: ChannelMessagePreparedSendPayloadContext,
|
||||
) => ReplyPayload | null | undefined | Promise<ReplyPayload | null | undefined>;
|
||||
/**
|
||||
* Prefer this for channel-specific poll semantics or extra poll parameters.
|
||||
* Core only parses the shared poll model when falling back to `outbound.sendPoll`.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChannelMessageAdapterShape } from "../message/types.js";
|
||||
import type { ChannelSetupWizard, ChannelSetupWizardAdapter } from "./setup-wizard-types.js";
|
||||
import type { ChannelConfigSchema } from "./types.config.js";
|
||||
export type {
|
||||
@@ -85,6 +86,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
||||
conversationBindings?: ChannelConversationBindingSupport;
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
message?: ChannelMessageAdapterShape;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
agentPrompt?: ChannelAgentPromptAdapter;
|
||||
directory?: ChannelDirectoryAdapter;
|
||||
|
||||
56
src/channels/turn/delivery-result.test.ts
Normal file
56
src/channels/turn/delivery-result.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
|
||||
|
||||
describe("createChannelDeliveryResultFromReceipt", () => {
|
||||
it("keeps legacy messageIds while attaching the receipt", () => {
|
||||
const receipt = {
|
||||
primaryPlatformMessageId: "m1",
|
||||
platformMessageIds: ["m1", "m2"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
expect(
|
||||
createChannelDeliveryResultFromReceipt({
|
||||
receipt,
|
||||
threadId: "thread-1",
|
||||
replyToId: "reply-1",
|
||||
visibleReplySent: true,
|
||||
deliveryIntent: {
|
||||
id: "intent-1",
|
||||
kind: "outbound_queue",
|
||||
queuePolicy: "required",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
messageIds: ["m1", "m2"],
|
||||
receipt,
|
||||
threadId: "thread-1",
|
||||
replyToId: "reply-1",
|
||||
visibleReplySent: true,
|
||||
deliveryIntent: {
|
||||
id: "intent-1",
|
||||
kind: "outbound_queue",
|
||||
queuePolicy: "required",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves suppressed receipt results without synthetic message ids", () => {
|
||||
const receipt = {
|
||||
platformMessageIds: [],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
expect(
|
||||
createChannelDeliveryResultFromReceipt({
|
||||
receipt,
|
||||
visibleReplySent: false,
|
||||
}),
|
||||
).toEqual({
|
||||
receipt,
|
||||
visibleReplySent: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
21
src/channels/turn/delivery-result.ts
Normal file
21
src/channels/turn/delivery-result.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { listMessageReceiptPlatformIds } from "../message/receipt.js";
|
||||
import type { MessageReceipt } from "../message/types.js";
|
||||
import type { ChannelDeliveryIntent, ChannelDeliveryResult } from "./types.js";
|
||||
|
||||
export function createChannelDeliveryResultFromReceipt(params: {
|
||||
receipt: MessageReceipt;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
visibleReplySent?: boolean;
|
||||
deliveryIntent?: ChannelDeliveryIntent;
|
||||
}): ChannelDeliveryResult {
|
||||
const messageIds = listMessageReceiptPlatformIds(params.receipt);
|
||||
return {
|
||||
...(messageIds.length > 0 ? { messageIds } : {}),
|
||||
receipt: params.receipt,
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
||||
...(params.visibleReplySent === undefined ? {} : { visibleReplySent: params.visibleReplySent }),
|
||||
...(params.deliveryIntent ? { deliveryIntent: params.deliveryIntent } : {}),
|
||||
};
|
||||
}
|
||||
168
src/channels/turn/durable-delivery.test.ts
Normal file
168
src/channels/turn/durable-delivery.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutboundDurableFinalDeliverySupport: vi.fn(),
|
||||
sendDurableMessageBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../infra/outbound/deliver.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveOutboundDurableFinalDeliverySupport: mocks.resolveOutboundDurableFinalDeliverySupport,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../message/send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../message/send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendDurableMessageBatch: mocks.sendDurableMessageBatch,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
resolveDurableInboundReplyToId,
|
||||
} from "./durable-delivery.js";
|
||||
|
||||
describe("durable inbound reply delivery", () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolveOutboundDurableFinalDeliverySupport.mockReset();
|
||||
mocks.sendDurableMessageBatch.mockReset();
|
||||
mocks.resolveOutboundDurableFinalDeliverySupport.mockResolvedValue({ ok: true });
|
||||
mocks.sendDurableMessageBatch.mockResolvedValue({
|
||||
status: "sent",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "m1",
|
||||
platformMessageIds: ["m1"],
|
||||
parts: [{ platformMessageId: "m1", kind: "text", index: 0 }],
|
||||
sentAt: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit null reply targets instead of falling back to context ids", () => {
|
||||
expect(
|
||||
resolveDurableInboundReplyToId({
|
||||
replyToId: null,
|
||||
payload: { text: "plain reply" },
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
ReplyToIdFull: "context-full-reply",
|
||||
ReplyToId: "context-reply",
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to payload and context reply targets when no explicit null is provided", () => {
|
||||
expect(
|
||||
resolveDurableInboundReplyToId({
|
||||
payload: { text: "payload reply", replyToId: "payload-reply" },
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
ReplyToIdFull: "context-full-reply",
|
||||
ReplyToId: "context-reply",
|
||||
},
|
||||
}),
|
||||
).toBe("payload-reply");
|
||||
|
||||
expect(
|
||||
resolveDurableInboundReplyToId({
|
||||
payload: { text: "context reply" },
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
ReplyToIdFull: "context-full-reply",
|
||||
ReplyToId: "context-reply",
|
||||
},
|
||||
}),
|
||||
).toBe("context-full-reply");
|
||||
});
|
||||
|
||||
it("preserves explicit null thread targets instead of falling back to context thread", async () => {
|
||||
await deliverInboundReplyWithMessageSendContext({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
agentId: "main",
|
||||
info: { kind: "final" },
|
||||
payload: { text: "plain reply" },
|
||||
threadId: null,
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
OriginatingTo: "chat-1",
|
||||
MessageThreadId: "context-thread",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
threadId: null,
|
||||
durability: "best_effort",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not require unknown-send reconciliation for the default best-effort final path", async () => {
|
||||
await deliverInboundReplyWithMessageSendContext({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
agentId: "main",
|
||||
info: { kind: "final" },
|
||||
payload: { text: "final" },
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
OriginatingTo: "chat-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requirements: {
|
||||
text: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
durability: "best_effort",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses required durability when a caller explicitly requires unknown-send reconciliation", async () => {
|
||||
await deliverInboundReplyWithMessageSendContext({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
agentId: "main",
|
||||
info: { kind: "final" },
|
||||
payload: { text: "final" },
|
||||
requiredCapabilities: {
|
||||
text: true,
|
||||
reconcileUnknownSend: true,
|
||||
},
|
||||
ctxPayload: {
|
||||
CommandAuthorized: true,
|
||||
OriginatingTo: "chat-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requirements: {
|
||||
text: true,
|
||||
reconcileUnknownSend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
durability: "required",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
209
src/channels/turn/durable-delivery.ts
Normal file
209
src/channels/turn/durable-delivery.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeDeliverableOutboundChannel } from "../../infra/outbound/channel-resolution.js";
|
||||
import {
|
||||
type DeliverOutboundPayloadsParams,
|
||||
type DurableFinalDeliveryRequirement,
|
||||
type DurableFinalDeliveryRequirements,
|
||||
type OutboundDeliveryIntent,
|
||||
resolveOutboundDurableFinalDeliverySupport,
|
||||
} from "../../infra/outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { deriveDurableFinalDeliveryRequirements } from "../message/capabilities.js";
|
||||
import { sendDurableMessageBatch } from "../message/send.js";
|
||||
import { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
|
||||
import type { ChannelDeliveryInfo, ChannelDeliveryResult } from "./types.js";
|
||||
|
||||
export type DurableInboundReplyDeliveryOptions = Pick<
|
||||
DeliverOutboundPayloadsParams,
|
||||
"deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId"
|
||||
> & {
|
||||
to?: string | null;
|
||||
replyToId?: string | null;
|
||||
requiredCapabilities?: DurableFinalDeliveryRequirements;
|
||||
};
|
||||
|
||||
export type DurableInboundReplyDeliveryParams = DurableInboundReplyDeliveryOptions & {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
agentId: string;
|
||||
ctxPayload: FinalizedMsgContext;
|
||||
payload: ReplyPayload;
|
||||
info: ChannelDeliveryInfo;
|
||||
};
|
||||
|
||||
export type DurableInboundReplyDeliveryResult =
|
||||
| { status: "not_applicable"; reason: "non_final" }
|
||||
| {
|
||||
status: "unsupported";
|
||||
reason:
|
||||
| "missing_channel"
|
||||
| "missing_target"
|
||||
| "missing_outbound_handler"
|
||||
| "capability_mismatch";
|
||||
capability?: DurableFinalDeliveryRequirement;
|
||||
}
|
||||
| { status: "handled_visible"; delivery: ChannelDeliveryResult }
|
||||
| { status: "handled_no_send"; reason: "no_visible_result"; delivery: ChannelDeliveryResult }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
function resolveDeliveryTarget(params: DurableInboundReplyDeliveryParams): string | undefined {
|
||||
return (
|
||||
normalizeOptionalString(params.to) ??
|
||||
normalizeOptionalString(params.ctxPayload.OriginatingTo) ??
|
||||
normalizeOptionalString(params.ctxPayload.To)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDurableInboundReplyToId(
|
||||
params: Pick<DurableInboundReplyDeliveryParams, "ctxPayload" | "payload" | "replyToId">,
|
||||
): string | null | undefined {
|
||||
if (params.replyToId === null || params.payload.replyToId === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
normalizeOptionalString(params.replyToId) ??
|
||||
normalizeOptionalString(params.payload.replyToId) ??
|
||||
normalizeOptionalString(params.ctxPayload.ReplyToIdFull) ??
|
||||
normalizeOptionalString(params.ctxPayload.ReplyToId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDurableInboundReplyThreadId(
|
||||
params: DurableInboundReplyDeliveryParams,
|
||||
): string | number | null | undefined {
|
||||
if ("threadId" in params) {
|
||||
return params.threadId;
|
||||
}
|
||||
return params.ctxPayload.MessageThreadId;
|
||||
}
|
||||
|
||||
function stringifyThreadId(value: string | number | null | undefined): string | undefined {
|
||||
return value == null ? undefined : String(value);
|
||||
}
|
||||
|
||||
function toDeliveryIntent(intent: OutboundDeliveryIntent): ChannelDeliveryResult["deliveryIntent"] {
|
||||
return {
|
||||
id: intent.id,
|
||||
kind: "outbound_queue",
|
||||
queuePolicy: intent.queuePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDurableInboundReplyDeliveryHandled(
|
||||
result: DurableInboundReplyDeliveryResult,
|
||||
): result is Extract<
|
||||
DurableInboundReplyDeliveryResult,
|
||||
{ status: "handled_visible" | "handled_no_send" }
|
||||
> {
|
||||
return result.status === "handled_visible" || result.status === "handled_no_send";
|
||||
}
|
||||
|
||||
export function throwIfDurableInboundReplyDeliveryFailed(
|
||||
result: DurableInboundReplyDeliveryResult,
|
||||
): void {
|
||||
if (result.status === "failed") {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverInboundReplyWithMessageSendContext(
|
||||
params: DurableInboundReplyDeliveryParams,
|
||||
): Promise<DurableInboundReplyDeliveryResult> {
|
||||
if (params.info.kind !== "final") {
|
||||
return { status: "not_applicable", reason: "non_final" };
|
||||
}
|
||||
|
||||
const channel = normalizeDeliverableOutboundChannel(params.channel);
|
||||
const to = resolveDeliveryTarget(params);
|
||||
if (!channel) {
|
||||
return { status: "unsupported", reason: "missing_channel" };
|
||||
}
|
||||
if (!to) {
|
||||
return { status: "unsupported", reason: "missing_target" };
|
||||
}
|
||||
|
||||
const replyToId = resolveDurableInboundReplyToId(params);
|
||||
const threadId = resolveDurableInboundReplyThreadId(params);
|
||||
const requiredCapabilities =
|
||||
params.requiredCapabilities ??
|
||||
deriveDurableFinalDeliveryRequirements({
|
||||
payload: params.payload,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent: params.silent,
|
||||
});
|
||||
const durability =
|
||||
requiredCapabilities.reconcileUnknownSend === true ? "required" : "best_effort";
|
||||
|
||||
let support: Awaited<ReturnType<typeof resolveOutboundDurableFinalDeliverySupport>>;
|
||||
try {
|
||||
support = await resolveOutboundDurableFinalDeliverySupport({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
requirements: requiredCapabilities,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return { status: "failed", error: err };
|
||||
}
|
||||
if (!support.ok) {
|
||||
return {
|
||||
status: "unsupported",
|
||||
reason: support.reason,
|
||||
...(support.capability ? { capability: support.capability } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const session = buildOutboundSessionContext({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.ctxPayload.SessionKey,
|
||||
policySessionKey: params.ctxPayload.RuntimePolicySessionKey,
|
||||
conversationType: params.ctxPayload.ChatType,
|
||||
agentId: params.agentId,
|
||||
requesterAccountId: params.accountId ?? params.ctxPayload.AccountId,
|
||||
requesterSenderId: params.ctxPayload.SenderId ?? params.ctxPayload.From,
|
||||
requesterSenderName: params.ctxPayload.SenderName,
|
||||
requesterSenderUsername: params.ctxPayload.SenderUsername,
|
||||
requesterSenderE164: params.ctxPayload.SenderE164,
|
||||
});
|
||||
|
||||
const send = await sendDurableMessageBatch({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
to,
|
||||
accountId: params.accountId,
|
||||
payloads: [params.payload],
|
||||
threadId,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
formatting: params.formatting,
|
||||
identity: params.identity,
|
||||
deps: params.deps,
|
||||
mediaAccess: params.mediaAccess,
|
||||
silent: params.silent,
|
||||
durability,
|
||||
session,
|
||||
gatewayClientScopes: params.ctxPayload.GatewayClientScopes,
|
||||
});
|
||||
if (send.status === "failed") {
|
||||
return { status: "failed" as const, error: send.error };
|
||||
}
|
||||
|
||||
const delivery = createChannelDeliveryResultFromReceipt({
|
||||
receipt: send.receipt,
|
||||
threadId: stringifyThreadId(threadId),
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
visibleReplySent: send.status === "sent",
|
||||
...(send.deliveryIntent ? { deliveryIntent: toDeliveryIntent(send.deliveryIntent) } : {}),
|
||||
});
|
||||
if (send.status === "suppressed") {
|
||||
return { status: "handled_no_send", reason: "no_visible_result", delivery };
|
||||
}
|
||||
return { status: "handled_visible", delivery };
|
||||
}
|
||||
|
||||
/** @deprecated Use `deliverInboundReplyWithMessageSendContext`. */
|
||||
export const deliverDurableInboundReplyPayload = deliverInboundReplyWithMessageSendContext;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
|
||||
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -13,6 +14,18 @@ import {
|
||||
runChannelTurn,
|
||||
} from "./kernel.js";
|
||||
|
||||
const deliverOutboundPayloads = vi.hoisted(() => vi.fn());
|
||||
const resolveOutboundDurableFinalDeliverySupport = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../infra/outbound/deliver.js")>();
|
||||
return {
|
||||
...actual,
|
||||
deliverOutboundPayloads,
|
||||
resolveOutboundDurableFinalDeliverySupport,
|
||||
};
|
||||
});
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
function createCtx(overrides: Partial<FinalizedMsgContext> = {}): FinalizedMsgContext {
|
||||
@@ -50,6 +63,357 @@ function createDispatch(
|
||||
}
|
||||
|
||||
describe("channel turn kernel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveOutboundDurableFinalDeliverySupport.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("routes assembled final replies through durable outbound delivery", async () => {
|
||||
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tg-1" }]);
|
||||
const deliver = vi.fn();
|
||||
const recordInboundSession = createRecordInboundSession();
|
||||
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
|
||||
|
||||
const result = await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({
|
||||
To: "123",
|
||||
OriginatingTo: "123",
|
||||
MessageThreadId: 777,
|
||||
AccountId: "acct",
|
||||
ChatType: "group",
|
||||
SenderId: "sender-1",
|
||||
}),
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver, durable: { replyToMode: "first" } },
|
||||
});
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
accountId: "acct",
|
||||
payloads: [expect.objectContaining({ text: "reply" })],
|
||||
queuePolicy: "best_effort",
|
||||
replyToMode: "first",
|
||||
threadId: 777,
|
||||
session: expect.objectContaining({
|
||||
key: "agent:main:test:peer",
|
||||
agentId: "main",
|
||||
requesterAccountId: "acct",
|
||||
requesterSenderId: "sender-1",
|
||||
conversationType: "group",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
requirements: {
|
||||
text: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns durable delivery result to the buffered dispatcher", async () => {
|
||||
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tg-1" }, { messageId: "tg-2" }]);
|
||||
let deliveredResult: unknown;
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
|
||||
deliveredResult = await params.dispatcherOptions.deliver(
|
||||
{ text: "reply" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
},
|
||||
) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver: vi.fn(), durable: { replyToMode: "first" } },
|
||||
});
|
||||
|
||||
expect(deliveredResult).toEqual(
|
||||
expect.objectContaining({
|
||||
messageIds: ["tg-1", "tg-2"],
|
||||
receipt: expect.objectContaining({
|
||||
platformMessageIds: ["tg-1", "tg-2"],
|
||||
}),
|
||||
visibleReplySent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prepares payloads before durable enqueue and observes handled delivery", async () => {
|
||||
deliverOutboundPayloads.mockResolvedValueOnce([{ messageId: "tlon-1" }]);
|
||||
const onDelivered = vi.fn();
|
||||
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "tlon",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:tlon:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({ To: "chat/~nec/general", OriginatingTo: "chat/~nec/general" }),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: vi.fn(),
|
||||
durable: (payload) => ({
|
||||
replyToMode: "first",
|
||||
requiredCapabilities: { text: payload.text?.includes("Generated") === true },
|
||||
}),
|
||||
preparePayload: (payload) => ({
|
||||
...payload,
|
||||
text: `${payload.text}\n\n_[Generated by test]_`,
|
||||
}),
|
||||
onDelivered,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [expect.objectContaining({ text: "reply\n\n_[Generated by test]_" })],
|
||||
}),
|
||||
);
|
||||
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requirements: {
|
||||
text: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(onDelivered).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "reply\n\n_[Generated by test]_" }),
|
||||
{ kind: "final" },
|
||||
expect.objectContaining({ visibleReplySent: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back before queueing when durable outbound delivery is unsupported", async () => {
|
||||
resolveOutboundDurableFinalDeliverySupport.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: "missing_outbound_handler",
|
||||
});
|
||||
const deliver = vi.fn(async () => ({ messageIds: ["legacy-1"], visibleReplySent: true }));
|
||||
let deliveredResult: unknown;
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
|
||||
deliveredResult = await params.dispatcherOptions.deliver(
|
||||
{ text: "reply" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
},
|
||||
) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver, durable: { replyToMode: "first" } },
|
||||
});
|
||||
|
||||
expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
requirements: {
|
||||
text: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" });
|
||||
expect(deliveredResult).toEqual(
|
||||
expect.objectContaining({
|
||||
messageIds: ["legacy-1"],
|
||||
visibleReplySent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats durable outbound support preflight failures as terminal", async () => {
|
||||
resolveOutboundDurableFinalDeliverySupport.mockRejectedValueOnce(new Error("preflight failed"));
|
||||
const deliver = vi.fn(async () => ({ messageIds: ["legacy-1"], visibleReplySent: true }));
|
||||
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
|
||||
|
||||
await expect(
|
||||
dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver, durable: { replyToMode: "first" } },
|
||||
}),
|
||||
).rejects.toThrow("preflight failed");
|
||||
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns custom delivery result to the buffered dispatcher", async () => {
|
||||
let deliveredResult: unknown;
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
|
||||
deliveredResult = await params.dispatcherOptions.deliver(
|
||||
{ text: "reply" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
},
|
||||
) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "test",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:test:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx(),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: false,
|
||||
deliver: vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true })),
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliveredResult).toEqual(
|
||||
expect.objectContaining({
|
||||
messageIds: ["local-1"],
|
||||
visibleReplySent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use durable outbound delivery when durable options are omitted", async () => {
|
||||
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
|
||||
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "acct",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx({ To: "123", OriginatingTo: "123" }),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver },
|
||||
});
|
||||
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" });
|
||||
});
|
||||
|
||||
it("prepares payloads and observes legacy delivery results", async () => {
|
||||
const onDelivered = vi.fn();
|
||||
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
|
||||
const dispatchReplyWithBufferedBlockDispatcher = createDispatch();
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "test",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:test:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx(),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver,
|
||||
preparePayload: (payload) => ({ ...payload, text: `${payload.text}!` }),
|
||||
onDelivered,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliver).toHaveBeenCalledWith({ text: "reply!" }, { kind: "final" });
|
||||
expect(onDelivered).toHaveBeenCalledWith(
|
||||
{ text: "reply!" },
|
||||
{ kind: "final" },
|
||||
expect.objectContaining({ messageIds: ["local-1"], visibleReplySent: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("assembles channel message reply pipeline options inside the turn kernel", async () => {
|
||||
const deliver = vi.fn(async () => ({ messageIds: ["local-1"], visibleReplySent: true }));
|
||||
const transformReplyPayload = vi.fn((payload: ReplyPayload) => ({
|
||||
...payload,
|
||||
text: `${payload.text} from pipeline`,
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (params: Parameters<DispatchReplyWithBufferedBlockDispatcher>[0]) => {
|
||||
const transformed = params.dispatcherOptions.transformReplyPayload?.({ text: "reply" });
|
||||
await params.dispatcherOptions.deliver(transformed ?? { text: "missing" }, {
|
||||
kind: "final",
|
||||
});
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
},
|
||||
) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
await dispatchAssembledChannelTurn({
|
||||
cfg,
|
||||
channel: "test",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:test:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: createCtx(),
|
||||
recordInboundSession: createRecordInboundSession(),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: { deliver },
|
||||
replyPipeline: { transformReplyPayload },
|
||||
});
|
||||
|
||||
expect(transformReplyPayload).toHaveBeenCalledWith({ text: "reply" });
|
||||
expect(deliver).toHaveBeenCalledWith({ text: "reply from pipeline" }, { kind: "final" });
|
||||
});
|
||||
|
||||
it("records inbound session before dispatching delivery", async () => {
|
||||
const events: string[] = [];
|
||||
const deliver = vi.fn(async () => {
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import { clearHistoryEntriesIfEnabled } from "../../auto-reply/reply/history.js";
|
||||
import { createChannelReplyPipeline } from "../message/reply-pipeline.js";
|
||||
import type { CreateChannelReplyPipelineParams } from "../message/reply-pipeline.js";
|
||||
import { EMPTY_CHANNEL_TURN_DISPATCH_COUNTS } from "./dispatch-result.js";
|
||||
import {
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
isDurableInboundReplyDeliveryHandled,
|
||||
throwIfDurableInboundReplyDeliveryFailed,
|
||||
} from "./durable-delivery.js";
|
||||
export { buildChannelTurnContext, filterChannelTurnSupplementalContext } from "./context.js";
|
||||
export type { BuildChannelTurnContextParams } from "./context.js";
|
||||
export {
|
||||
deliverDurableInboundReplyPayload,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
isDurableInboundReplyDeliveryHandled,
|
||||
throwIfDurableInboundReplyDeliveryFailed,
|
||||
} from "./durable-delivery.js";
|
||||
export type {
|
||||
DurableInboundReplyDeliveryOptions,
|
||||
DurableInboundReplyDeliveryParams,
|
||||
DurableInboundReplyDeliveryResult,
|
||||
} from "./durable-delivery.js";
|
||||
import type {
|
||||
AssembledChannelTurn,
|
||||
ChannelEventClass,
|
||||
@@ -10,6 +28,7 @@ import type {
|
||||
ChannelTurnDeliveryAdapter,
|
||||
ChannelTurnHistoryFinalizeOptions,
|
||||
ChannelTurnLogEvent,
|
||||
ChannelTurnReplyPipelineOptions,
|
||||
ChannelTurnResolved,
|
||||
ChannelTurnResult,
|
||||
DispatchedChannelTurnResult,
|
||||
@@ -18,6 +37,7 @@ import type {
|
||||
RunChannelTurnParams,
|
||||
RunResolvedChannelTurnParams,
|
||||
} from "./types.js";
|
||||
export { createChannelDeliveryResultFromReceipt } from "./delivery-result.js";
|
||||
export {
|
||||
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
|
||||
hasFinalChannelTurnDispatch,
|
||||
@@ -39,6 +59,7 @@ export type {
|
||||
ChannelTurnDispatcherOptions,
|
||||
ChannelTurnLogEvent,
|
||||
ChannelTurnRecordOptions,
|
||||
ChannelTurnReplyPipelineOptions,
|
||||
ChannelTurnResolved,
|
||||
ChannelTurnResult,
|
||||
DispatchedChannelTurnResult,
|
||||
@@ -61,6 +82,18 @@ const DEFAULT_EVENT_CLASS: ChannelEventClass = {
|
||||
canStartAgentTurn: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Compatibility assembly for legacy buffered reply dispatchers.
|
||||
* New channel plugins should expose `defineChannelMessageAdapter(...)` from
|
||||
* `openclaw/plugin-sdk/channel-message` and route send/receive behavior through
|
||||
* the message lifecycle helpers.
|
||||
*/
|
||||
export function createChannelTurnReplyPipeline(
|
||||
params: CreateChannelReplyPipelineParams,
|
||||
): ReturnType<typeof createChannelReplyPipeline> {
|
||||
return createChannelReplyPipeline(params);
|
||||
}
|
||||
|
||||
function isAdmission(value: unknown): value is ChannelTurnAdmission {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
@@ -113,6 +146,34 @@ function clearPendingHistoryAfterTurn(params?: ChannelTurnHistoryFinalizeOptions
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAssembledReplyPipeline(
|
||||
params: AssembledChannelTurn,
|
||||
): Pick<AssembledChannelTurn, "dispatcherOptions" | "replyOptions"> {
|
||||
if (!params.replyPipeline) {
|
||||
return {
|
||||
dispatcherOptions: params.dispatcherOptions,
|
||||
replyOptions: params.replyOptions,
|
||||
};
|
||||
}
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
...params.replyPipeline,
|
||||
});
|
||||
return {
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
...params.dispatcherOptions,
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
...params.replyOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveObserveOnlyDispatchResult<TDispatchResult>(
|
||||
params: PreparedChannelTurn<TDispatchResult>,
|
||||
): TDispatchResult {
|
||||
@@ -125,6 +186,7 @@ function resolveObserveOnlyDispatchResult<TDispatchResult>(
|
||||
export async function dispatchAssembledChannelTurn(
|
||||
params: AssembledChannelTurn,
|
||||
): Promise<DispatchedChannelTurnResult> {
|
||||
const replyPipeline = resolveAssembledReplyPipeline(params);
|
||||
return await runPreparedChannelTurnCore(
|
||||
{
|
||||
channel: params.channel,
|
||||
@@ -143,13 +205,39 @@ export async function dispatchAssembledChannelTurn(
|
||||
ctx: params.ctxPayload,
|
||||
cfg: params.cfg,
|
||||
dispatcherOptions: {
|
||||
...params.dispatcherOptions,
|
||||
...replyPipeline.dispatcherOptions,
|
||||
deliver: async (payload: ReplyPayload, info) => {
|
||||
await params.delivery.deliver(payload, info);
|
||||
const preparedPayload = params.delivery.preparePayload
|
||||
? await params.delivery.preparePayload(payload, info)
|
||||
: payload;
|
||||
const durableOptions =
|
||||
typeof params.delivery.durable === "function"
|
||||
? await params.delivery.durable(preparedPayload, info)
|
||||
: params.delivery.durable;
|
||||
if (durableOptions) {
|
||||
const durable = await deliverInboundReplyWithMessageSendContext({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
agentId: params.agentId,
|
||||
ctxPayload: params.ctxPayload,
|
||||
payload: preparedPayload,
|
||||
info,
|
||||
...durableOptions,
|
||||
});
|
||||
throwIfDurableInboundReplyDeliveryFailed(durable);
|
||||
if (isDurableInboundReplyDeliveryHandled(durable)) {
|
||||
await params.delivery.onDelivered?.(preparedPayload, info, durable.delivery);
|
||||
return durable.delivery;
|
||||
}
|
||||
}
|
||||
const result = await params.delivery.deliver(preparedPayload, info);
|
||||
await params.delivery.onDelivered?.(preparedPayload, info, result);
|
||||
return result;
|
||||
},
|
||||
onError: params.delivery.onError,
|
||||
},
|
||||
replyOptions: params.replyOptions,
|
||||
replyOptions: replyPipeline.replyOptions,
|
||||
replyResolver: params.replyResolver,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -9,6 +9,13 @@ import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.
|
||||
import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { GroupKeyResolution } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type {
|
||||
DeliverOutboundPayloadsParams,
|
||||
DurableFinalDeliveryRequirements,
|
||||
OutboundDeliveryQueuePolicy,
|
||||
} from "../../infra/outbound/deliver.js";
|
||||
import type { CreateChannelReplyPipelineParams } from "../message/reply-pipeline.js";
|
||||
import type { MessageReceipt } from "../message/types.js";
|
||||
import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.types.js";
|
||||
|
||||
export type ChannelTurnAdmission =
|
||||
@@ -168,18 +175,54 @@ export type ChannelDeliveryInfo = {
|
||||
kind: ReplyDispatchKind;
|
||||
};
|
||||
|
||||
export type ChannelDeliveryIntent = {
|
||||
id: string;
|
||||
kind: "outbound_queue";
|
||||
queuePolicy: OutboundDeliveryQueuePolicy;
|
||||
};
|
||||
|
||||
export type ChannelDeliveryResult = {
|
||||
messageIds?: string[];
|
||||
receipt?: MessageReceipt;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
visibleReplySent?: boolean;
|
||||
deliveryIntent?: ChannelDeliveryIntent;
|
||||
};
|
||||
|
||||
export type ChannelTurnDurableDeliveryOptions = Pick<
|
||||
DeliverOutboundPayloadsParams,
|
||||
"deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId"
|
||||
> & {
|
||||
to?: string | null;
|
||||
replyToId?: string | null;
|
||||
requiredCapabilities?: DurableFinalDeliveryRequirements;
|
||||
};
|
||||
|
||||
export type ChannelTurnDeliveryAdapter = {
|
||||
preparePayload?: (
|
||||
payload: ReplyPayload,
|
||||
info: ChannelDeliveryInfo,
|
||||
) => Promise<ReplyPayload> | ReplyPayload;
|
||||
deliver: (
|
||||
payload: ReplyPayload,
|
||||
info: ChannelDeliveryInfo,
|
||||
) => Promise<ChannelDeliveryResult | void>;
|
||||
durable?:
|
||||
| false
|
||||
| ChannelTurnDurableDeliveryOptions
|
||||
| ((
|
||||
payload: ReplyPayload,
|
||||
info: ChannelDeliveryInfo,
|
||||
) =>
|
||||
| false
|
||||
| ChannelTurnDurableDeliveryOptions
|
||||
| Promise<false | ChannelTurnDurableDeliveryOptions>);
|
||||
onDelivered?: (
|
||||
payload: ReplyPayload,
|
||||
info: ChannelDeliveryInfo,
|
||||
result: ChannelDeliveryResult | void,
|
||||
) => Promise<void> | void;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
|
||||
@@ -203,6 +246,11 @@ export type ChannelTurnDispatcherOptions = Omit<
|
||||
"deliver" | "onError"
|
||||
>;
|
||||
|
||||
export type ChannelTurnReplyPipelineOptions = Omit<
|
||||
CreateChannelReplyPipelineParams,
|
||||
"cfg" | "agentId" | "channel" | "accountId"
|
||||
>;
|
||||
|
||||
export type AssembledChannelTurn = {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
@@ -214,6 +262,7 @@ export type AssembledChannelTurn = {
|
||||
recordInboundSession: RecordInboundSession;
|
||||
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
|
||||
delivery: ChannelTurnDeliveryAdapter;
|
||||
replyPipeline?: ChannelTurnReplyPipelineOptions;
|
||||
dispatcherOptions?: ChannelTurnDispatcherOptions;
|
||||
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
|
||||
replyResolver?: GetReplyFromConfig;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../plugins/plugin-load-profile.js";
|
||||
import {
|
||||
getCachedPluginSourceModuleLoader,
|
||||
type PluginModuleLoaderFactory,
|
||||
type PluginModuleLoaderCache,
|
||||
} from "../plugins/plugin-module-loader-cache.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
@@ -132,7 +133,9 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
|
||||
features?: BundledChannelSetupEntryFeatures;
|
||||
};
|
||||
|
||||
export type BundledEntryModuleLoadOptions = Record<string, never>;
|
||||
export type BundledEntryModuleLoadOptions = {
|
||||
createLoaderForTest?: PluginModuleLoaderFactory;
|
||||
};
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const moduleLoaders: PluginModuleLoaderCache = new Map();
|
||||
@@ -329,13 +332,14 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string)
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceModuleLoader(modulePath: string) {
|
||||
function getSourceModuleLoader(modulePath: string, options: BundledEntryModuleLoadOptions) {
|
||||
return getCachedPluginSourceModuleLoader({
|
||||
cache: moduleLoaders,
|
||||
modulePath,
|
||||
importerUrl: import.meta.url,
|
||||
preferBuiltDist: true,
|
||||
loaderFilename: import.meta.url,
|
||||
...(options.createLoaderForTest ? { createLoader: options.createLoaderForTest } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,7 +356,7 @@ function canTryNodeRequireBuiltModule(modulePath: string): boolean {
|
||||
function loadBundledEntryModuleSync(
|
||||
importMetaUrl: string,
|
||||
specifier: string,
|
||||
_options: BundledEntryModuleLoadOptions = {},
|
||||
options: BundledEntryModuleLoadOptions = {},
|
||||
): unknown {
|
||||
const modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier);
|
||||
const cached = loadedModuleExports.get(modulePath);
|
||||
@@ -367,12 +371,12 @@ function loadBundledEntryModuleSync(
|
||||
try {
|
||||
loaded = nodeRequire(modulePath);
|
||||
} catch {
|
||||
const moduleLoader = getSourceModuleLoader(modulePath);
|
||||
const moduleLoader = getSourceModuleLoader(modulePath, options);
|
||||
sourceLoaderReadyMs = profile ? performance.now() : 0;
|
||||
loaded = moduleLoader(toSafeImportPath(modulePath));
|
||||
}
|
||||
} else {
|
||||
const moduleLoader = getSourceModuleLoader(modulePath);
|
||||
const moduleLoader = getSourceModuleLoader(modulePath, options);
|
||||
sourceLoaderReadyMs = profile ? performance.now() : 0;
|
||||
loaded = moduleLoader(toSafeImportPath(modulePath));
|
||||
}
|
||||
|
||||
28
src/plugin-sdk/channel-message-runtime.ts
Normal file
28
src/plugin-sdk/channel-message-runtime.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
buildChannelMessageReplyDispatchBase,
|
||||
dispatchChannelMessageReplyWithBase,
|
||||
hasFinalChannelMessageReplyDispatch,
|
||||
hasVisibleChannelMessageReplyDispatch,
|
||||
recordChannelMessageReplyDispatch,
|
||||
resolveChannelMessageReplyDispatchCounts,
|
||||
} from "./inbound-reply-dispatch.js";
|
||||
export {
|
||||
createChannelTurnReplyPipeline,
|
||||
deliverDurableInboundReplyPayload,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
} from "../channels/turn/kernel.js";
|
||||
export type {
|
||||
DurableInboundReplyDeliveryOptions,
|
||||
DurableInboundReplyDeliveryParams,
|
||||
DurableInboundReplyDeliveryResult,
|
||||
} from "../channels/turn/kernel.js";
|
||||
export {
|
||||
sendDurableMessageBatch,
|
||||
withDurableMessageSendContext,
|
||||
} from "../channels/message/runtime.js";
|
||||
export type {
|
||||
DurableMessageBatchSendParams,
|
||||
DurableMessageBatchSendResult,
|
||||
DurableMessageSendContext,
|
||||
DurableMessageSendContextParams,
|
||||
} from "../channels/message/runtime.js";
|
||||
62
src/plugin-sdk/channel-message.test.ts
Normal file
62
src/plugin-sdk/channel-message.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineChannelMessageAdapter } from "./channel-message.js";
|
||||
|
||||
describe("defineChannelMessageAdapter", () => {
|
||||
it("keeps new and legacy channel plugin SDK subpaths importable", async () => {
|
||||
const [channelMessage, channelMessageRuntime, channelReplyPipeline, compat] = await Promise.all(
|
||||
[
|
||||
import("openclaw/plugin-sdk/channel-message"),
|
||||
import("openclaw/plugin-sdk/channel-message-runtime"),
|
||||
import("openclaw/plugin-sdk/channel-reply-pipeline"),
|
||||
import("openclaw/plugin-sdk/compat"),
|
||||
],
|
||||
);
|
||||
|
||||
expect(channelMessage.createChannelMessageReplyPipeline).toBe(
|
||||
channelReplyPipeline.createChannelReplyPipeline,
|
||||
);
|
||||
expect(channelMessage.createReplyPrefixOptions).toBe(
|
||||
channelReplyPipeline.createReplyPrefixOptions,
|
||||
);
|
||||
expect(channelMessage.createTypingCallbacks).toBe(channelReplyPipeline.createTypingCallbacks);
|
||||
expect(typeof channelMessageRuntime.sendDurableMessageBatch).toBe("function");
|
||||
expect(typeof compat.createChannelReplyPipeline).toBe("function");
|
||||
});
|
||||
|
||||
it("defaults new message adapters to plugin-owned receive acknowledgement", () => {
|
||||
const adapter = defineChannelMessageAdapter({
|
||||
id: "demo",
|
||||
durableFinal: { capabilities: { text: true } },
|
||||
send: {
|
||||
text: vi.fn(async () => ({
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "msg-1",
|
||||
platformMessageIds: ["msg-1"],
|
||||
parts: [],
|
||||
sentAt: 123,
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.receive).toEqual({
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit receive acknowledgement policy declarations", () => {
|
||||
const adapter = defineChannelMessageAdapter({
|
||||
id: "demo",
|
||||
receive: {
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.receive).toEqual({
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
});
|
||||
});
|
||||
});
|
||||
221
src/plugin-sdk/channel-message.ts
Normal file
221
src/plugin-sdk/channel-message.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
ChannelMessageAdapter,
|
||||
ChannelMessageAdapterShape,
|
||||
} from "../channels/message/index.js";
|
||||
import type { ChannelMessageReceiveAdapterShape } from "../channels/message/index.js";
|
||||
import type {
|
||||
DurableMessageBatchSendParams,
|
||||
DurableMessageBatchSendResult,
|
||||
DurableMessageSendContext,
|
||||
DurableMessageSendContextParams,
|
||||
} from "../channels/message/runtime.js";
|
||||
import {
|
||||
hasFinalChannelTurnDispatch,
|
||||
hasVisibleChannelTurnDispatch,
|
||||
resolveChannelTurnDispatchCounts,
|
||||
} from "../channels/turn/dispatch-result.js";
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
type CreateChannelReplyPipelineParams,
|
||||
} from "./channel-reply-core.js";
|
||||
export type {
|
||||
DurableInboundReplyDeliveryOptions,
|
||||
DurableInboundReplyDeliveryParams,
|
||||
DurableInboundReplyDeliveryResult,
|
||||
} from "../channels/turn/kernel.js";
|
||||
export type {
|
||||
DurableMessageBatchSendParams,
|
||||
DurableMessageBatchSendResult,
|
||||
DurableMessageSendContext,
|
||||
DurableMessageSendContextParams,
|
||||
} from "../channels/message/runtime.js";
|
||||
export {
|
||||
createChannelReplyPipeline as createChannelMessageReplyPipeline,
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
resolveChannelSourceReplyDeliveryMode as resolveChannelMessageSourceReplyDeliveryMode,
|
||||
} from "./channel-reply-core.js";
|
||||
|
||||
export {
|
||||
classifyDurableSendRecoveryState,
|
||||
createChannelMessageAdapterFromOutbound,
|
||||
createMessageReceiptFromOutboundResults,
|
||||
listMessageReceiptPlatformIds,
|
||||
createMessageReceiveContext,
|
||||
createPreviewMessageReceipt,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deriveDurableFinalDeliveryRequirements,
|
||||
deliverFinalizableLivePreview,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
listDeclaredChannelMessageLiveCapabilities,
|
||||
listDeclaredDurableFinalCapabilities,
|
||||
listDeclaredLivePreviewFinalizerCapabilities,
|
||||
listDeclaredReceiveAckPolicies,
|
||||
createLiveMessageState,
|
||||
createDurableMessageStateRecord,
|
||||
markLiveMessageCancelled,
|
||||
markLiveMessageFinalized,
|
||||
markLiveMessagePreviewUpdated,
|
||||
resolveMessageReceiptPrimaryId,
|
||||
shouldAckMessageAfterStage,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveCapabilityProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs,
|
||||
verifyChannelMessageReceiveAckPolicyProofs,
|
||||
verifyDurableFinalCapabilityProofs,
|
||||
verifyLivePreviewFinalizerCapabilityProofs,
|
||||
} from "../channels/message/index.js";
|
||||
export type {
|
||||
ChannelMessageAdapter,
|
||||
ChannelMessageAdapterShape,
|
||||
ChannelMessageDurableFinalAdapter,
|
||||
ChannelMessageLiveFinalizerAdapterShape,
|
||||
ChannelMessageLiveAdapterShape,
|
||||
ChannelMessageLiveCapability,
|
||||
ChannelMessageOutboundBridgeAdapter,
|
||||
ChannelMessageOutboundBridgeResult,
|
||||
ChannelMessageReceiveAckPolicy,
|
||||
ChannelMessageReceiveAdapterShape,
|
||||
ChannelMessageSendAdapter,
|
||||
ChannelMessageSendAttemptContext,
|
||||
ChannelMessageSendAttemptKind,
|
||||
ChannelMessageSendCommitContext,
|
||||
ChannelMessageSendFailureContext,
|
||||
ChannelMessageSendLifecycleAdapter,
|
||||
ChannelMessageSendMediaContext,
|
||||
ChannelMessageSendPayloadContext,
|
||||
ChannelMessageSendResult,
|
||||
ChannelMessageSendSuccessContext,
|
||||
ChannelMessageSendTextContext,
|
||||
ChannelMessageUnknownSendContext,
|
||||
ChannelMessageUnknownSendReconciliationResult,
|
||||
CreateChannelReplyPipelineParams,
|
||||
CreateChannelMessageAdapterFromOutboundParams,
|
||||
DeriveDurableFinalDeliveryRequirementsParams,
|
||||
ChannelMessageLiveCapabilityProof,
|
||||
ChannelMessageLiveCapabilityProofMap,
|
||||
ChannelMessageLiveCapabilityProofResult,
|
||||
ChannelMessageReceiveAckPolicyProof,
|
||||
ChannelMessageReceiveAckPolicyProofMap,
|
||||
ChannelMessageReceiveAckPolicyProofResult,
|
||||
DurableFinalCapabilityProof,
|
||||
DurableFinalCapabilityProofMap,
|
||||
DurableFinalCapabilityProofResult,
|
||||
DurableFinalDeliveryCapability,
|
||||
DurableFinalDeliveryPayloadShape,
|
||||
DurableFinalDeliveryRequirementMap,
|
||||
DurableFinalRequirementExtras,
|
||||
DurableMessageSendIntent,
|
||||
DurableMessageSendState,
|
||||
DurableMessageStateRecord,
|
||||
FinalizableLivePreviewAdapter,
|
||||
LiveMessagePhase,
|
||||
LiveMessageState,
|
||||
LivePreviewFinalizerCapability,
|
||||
LivePreviewFinalizerCapabilityMap,
|
||||
LivePreviewFinalizerDraft,
|
||||
LivePreviewFinalizerCapabilityProof,
|
||||
LivePreviewFinalizerCapabilityProofMap,
|
||||
LivePreviewFinalizerCapabilityProofResult,
|
||||
LivePreviewFinalizerResult,
|
||||
LivePreviewFinalizerResultKind,
|
||||
MessageAckPolicy,
|
||||
MessageAckStage,
|
||||
MessageAckState,
|
||||
MessageReceiveContext,
|
||||
MessageSendContext,
|
||||
MessageDurabilityPolicy,
|
||||
MessageReceipt,
|
||||
MessageReceiptPart,
|
||||
MessageReceiptPartKind,
|
||||
MessageReceiptSourceResult,
|
||||
RenderedMessageBatch,
|
||||
RenderedMessageBatchPlan,
|
||||
RenderedMessageBatchPlanItem,
|
||||
RenderedMessageBatchPlanKind,
|
||||
} from "../channels/message/index.js";
|
||||
|
||||
type ChannelTurnKernelModule = typeof import("../channels/turn/kernel.js");
|
||||
type InboundReplyDispatchModule = typeof import("./inbound-reply-dispatch.js");
|
||||
|
||||
export function createChannelTurnReplyPipeline(params: CreateChannelReplyPipelineParams) {
|
||||
return createChannelReplyPipeline(params);
|
||||
}
|
||||
|
||||
export const hasFinalChannelMessageReplyDispatch = hasFinalChannelTurnDispatch;
|
||||
export const hasVisibleChannelMessageReplyDispatch = hasVisibleChannelTurnDispatch;
|
||||
export const resolveChannelMessageReplyDispatchCounts = resolveChannelTurnDispatchCounts;
|
||||
|
||||
export const buildChannelMessageReplyDispatchBase: InboundReplyDispatchModule["buildChannelMessageReplyDispatchBase"] =
|
||||
((params) => ({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
agentId: params.route.agentId,
|
||||
routeSessionKey: params.route.sessionKey,
|
||||
storePath: params.storePath,
|
||||
ctxPayload: params.ctxPayload,
|
||||
recordInboundSession: params.core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
})) as InboundReplyDispatchModule["buildChannelMessageReplyDispatchBase"];
|
||||
|
||||
export const dispatchChannelMessageReplyWithBase: InboundReplyDispatchModule["dispatchChannelMessageReplyWithBase"] =
|
||||
async (...args) => {
|
||||
const mod = await import("./inbound-reply-dispatch.js");
|
||||
return await mod.dispatchChannelMessageReplyWithBase(...args);
|
||||
};
|
||||
|
||||
export const recordChannelMessageReplyDispatch: InboundReplyDispatchModule["recordChannelMessageReplyDispatch"] =
|
||||
async (...args) => {
|
||||
const mod = await import("./inbound-reply-dispatch.js");
|
||||
return await mod.recordChannelMessageReplyDispatch(...args);
|
||||
};
|
||||
|
||||
export const deliverInboundReplyWithMessageSendContext: ChannelTurnKernelModule["deliverInboundReplyWithMessageSendContext"] =
|
||||
async (...args) => {
|
||||
const mod = await import("../channels/turn/kernel.js");
|
||||
return await mod.deliverInboundReplyWithMessageSendContext(...args);
|
||||
};
|
||||
|
||||
/** @deprecated Use `deliverInboundReplyWithMessageSendContext`. */
|
||||
export const deliverDurableInboundReplyPayload = deliverInboundReplyWithMessageSendContext;
|
||||
|
||||
export async function sendDurableMessageBatch(
|
||||
params: DurableMessageBatchSendParams,
|
||||
): Promise<DurableMessageBatchSendResult> {
|
||||
const mod = await import("../channels/message/runtime.js");
|
||||
return await mod.sendDurableMessageBatch(params);
|
||||
}
|
||||
|
||||
export async function withDurableMessageSendContext<T>(
|
||||
params: DurableMessageSendContextParams,
|
||||
run: (ctx: DurableMessageSendContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const mod = await import("../channels/message/runtime.js");
|
||||
return await mod.withDurableMessageSendContext(params, run);
|
||||
}
|
||||
|
||||
const defaultManualReceiveAdapter = {
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
} as const satisfies ChannelMessageReceiveAdapterShape;
|
||||
|
||||
type ChannelMessageAdapterWithDefaultReceive<TAdapter extends ChannelMessageAdapterShape> =
|
||||
TAdapter & {
|
||||
receive: TAdapter["receive"] extends undefined
|
||||
? typeof defaultManualReceiveAdapter
|
||||
: NonNullable<TAdapter["receive"]>;
|
||||
};
|
||||
|
||||
export function defineChannelMessageAdapter<const TAdapter extends ChannelMessageAdapterShape>(
|
||||
adapter: TAdapter,
|
||||
): ChannelMessageAdapter<ChannelMessageAdapterWithDefaultReceive<TAdapter>> {
|
||||
return {
|
||||
...adapter,
|
||||
receive: adapter.receive ?? defaultManualReceiveAdapter,
|
||||
} as ChannelMessageAdapter<ChannelMessageAdapterWithDefaultReceive<TAdapter>>;
|
||||
}
|
||||
17
src/plugin-sdk/channel-reply-core.ts
Normal file
17
src/plugin-sdk/channel-reply-core.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
createChannelReplyPipeline,
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "../channels/message/reply-pipeline.js";
|
||||
export type {
|
||||
ChannelReplyPipeline,
|
||||
CreateChannelReplyPipelineParams,
|
||||
CreateTypingCallbacksParams,
|
||||
ReplyPrefixContext,
|
||||
ReplyPrefixContextBundle,
|
||||
ReplyPrefixOptions,
|
||||
SourceReplyDeliveryMode,
|
||||
TypingCallbacks,
|
||||
} from "../channels/message/reply-pipeline.js";
|
||||
@@ -1,87 +1,21 @@
|
||||
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
|
||||
import {
|
||||
resolveSourceReplyDeliveryMode,
|
||||
type SourceReplyDeliveryModeContext,
|
||||
} from "../auto-reply/reply/source-reply-delivery-mode.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import {
|
||||
/**
|
||||
* @deprecated Legacy reply-pipeline subpath. New channel message code should
|
||||
* use `openclaw/plugin-sdk/channel-message`.
|
||||
*/
|
||||
|
||||
export {
|
||||
createChannelReplyPipeline,
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
type ReplyPrefixContextBundle,
|
||||
type ReplyPrefixOptions,
|
||||
} from "../channels/reply-prefix.js";
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
type CreateTypingCallbacksParams,
|
||||
type TypingCallbacks,
|
||||
} from "../channels/typing.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ReplyPayload } from "./reply-payload.js";
|
||||
|
||||
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
|
||||
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
|
||||
export type { CreateTypingCallbacksParams, TypingCallbacks };
|
||||
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
|
||||
export type { SourceReplyDeliveryMode };
|
||||
|
||||
export function resolveChannelSourceReplyDeliveryMode(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: SourceReplyDeliveryModeContext;
|
||||
requested?: SourceReplyDeliveryMode;
|
||||
messageToolAvailable?: boolean;
|
||||
}): SourceReplyDeliveryMode {
|
||||
return resolveSourceReplyDeliveryMode(params);
|
||||
}
|
||||
|
||||
export type ChannelReplyPipeline = ReplyPrefixOptions & {
|
||||
typingCallbacks?: TypingCallbacks;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
};
|
||||
|
||||
export function createChannelReplyPipeline(params: {
|
||||
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
|
||||
agentId: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
typing?: CreateTypingCallbacksParams;
|
||||
typingCallbacks?: TypingCallbacks;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
}): ChannelReplyPipeline {
|
||||
const channelId = params.channel
|
||||
? (normalizeChannelId(params.channel) ?? params.channel)
|
||||
: undefined;
|
||||
let plugin: ReturnType<typeof getChannelPlugin> | undefined;
|
||||
let pluginTransformResolved = false;
|
||||
const resolvePluginTransform = () => {
|
||||
if (pluginTransformResolved) {
|
||||
return plugin?.messaging?.transformReplyPayload;
|
||||
}
|
||||
pluginTransformResolved = true;
|
||||
plugin = channelId ? getChannelPlugin(channelId) : undefined;
|
||||
return plugin?.messaging?.transformReplyPayload;
|
||||
};
|
||||
const transformReplyPayload = params.transformReplyPayload
|
||||
? params.transformReplyPayload
|
||||
: channelId
|
||||
? (payload: ReplyPayload) =>
|
||||
resolvePluginTransform()?.({
|
||||
payload,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}) ?? payload
|
||||
: undefined;
|
||||
return {
|
||||
...createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
}),
|
||||
...(transformReplyPayload ? { transformReplyPayload } : {}),
|
||||
...(params.typingCallbacks
|
||||
? { typingCallbacks: params.typingCallbacks }
|
||||
: params.typing
|
||||
? { typingCallbacks: createTypingCallbacks(params.typing) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "./channel-reply-core.js";
|
||||
export type {
|
||||
ChannelReplyPipeline,
|
||||
CreateTypingCallbacksParams,
|
||||
ReplyPrefixContext,
|
||||
ReplyPrefixContextBundle,
|
||||
ReplyPrefixOptions,
|
||||
SourceReplyDeliveryMode,
|
||||
TypingCallbacks,
|
||||
} from "./channel-reply-core.js";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js";
|
||||
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
||||
import type { RecordInboundSession } from "../channels/session.types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
const deliverInboundReplyWithMessageSendContext = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../channels/turn/kernel.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/turn/kernel.js")>();
|
||||
return {
|
||||
...actual,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
createChannelMessageReplyPipeline,
|
||||
createReplyPrefixOptions as createChannelMessageReplyPrefixOptions,
|
||||
createReplyPrefixContext as createChannelMessageReplyPrefixContext,
|
||||
createTypingCallbacks as createChannelMessageTypingCallbacks,
|
||||
dispatchChannelMessageReplyWithBase,
|
||||
hasFinalChannelMessageReplyDispatch,
|
||||
recordChannelMessageReplyDispatch,
|
||||
resolveChannelMessageSourceReplyDeliveryMode,
|
||||
} from "./channel-message.js";
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "./channel-reply-pipeline.js";
|
||||
import {
|
||||
dispatchInboundReplyWithBase,
|
||||
hasFinalInboundReplyDispatch,
|
||||
hasVisibleInboundReplyDispatch,
|
||||
recordInboundSessionAndDispatchReply,
|
||||
@@ -11,6 +40,10 @@ import {
|
||||
} from "./inbound-reply-dispatch.js";
|
||||
|
||||
describe("recordInboundSessionAndDispatchReply", () => {
|
||||
beforeEach(() => {
|
||||
deliverInboundReplyWithMessageSendContext.mockReset();
|
||||
});
|
||||
|
||||
it("delegates record and dispatch through the channel turn kernel once", async () => {
|
||||
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
|
||||
const deliver = vi.fn(async () => undefined);
|
||||
@@ -38,7 +71,7 @@ describe("recordInboundSessionAndDispatchReply", () => {
|
||||
Surface: "test",
|
||||
} as FinalizedMsgContext;
|
||||
|
||||
await recordInboundSessionAndDispatchReply({
|
||||
await recordChannelMessageReplyDispatch({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
@@ -70,6 +103,116 @@ describe("recordInboundSessionAndDispatchReply", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps public compatibility delivery channel-owned when durable is omitted", async () => {
|
||||
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
|
||||
const deliver = vi.fn(async () => undefined);
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "hello" }, { kind: "final" });
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
}) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
await recordInboundSessionAndDispatchReply({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
routeSessionKey: "agent:main:telegram:peer",
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload: {
|
||||
Body: "body",
|
||||
RawBody: "body",
|
||||
CommandBody: "body",
|
||||
From: "sender",
|
||||
To: "123",
|
||||
OriginatingTo: "123",
|
||||
SessionKey: "agent:main:telegram:peer",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
} as FinalizedMsgContext,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
deliver,
|
||||
onRecordError: vi.fn(),
|
||||
onDispatchError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(deliver).toHaveBeenCalledWith({
|
||||
text: "hello",
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
sensitiveMedia: undefined,
|
||||
replyToId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards durable delivery options through the SDK convenience wrapper", async () => {
|
||||
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
|
||||
status: "handled_visible",
|
||||
delivery: {
|
||||
messageIds: ["queued-1"],
|
||||
visibleReplySent: true,
|
||||
},
|
||||
});
|
||||
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
|
||||
const deliver = vi.fn(async () => undefined);
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "hello durable" }, { kind: "final" });
|
||||
return {
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
};
|
||||
}) as DispatchReplyWithBufferedBlockDispatcher;
|
||||
const ctxPayload = {
|
||||
Body: "body",
|
||||
RawBody: "body",
|
||||
CommandBody: "body",
|
||||
From: "sender",
|
||||
To: "123",
|
||||
OriginatingTo: "123",
|
||||
SessionKey: "agent:main:telegram:peer",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
} as FinalizedMsgContext;
|
||||
|
||||
await dispatchChannelMessageReplyWithBase({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
route: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:peer",
|
||||
},
|
||||
storePath: "/tmp/sessions.json",
|
||||
ctxPayload,
|
||||
core: {
|
||||
channel: {
|
||||
session: { recordInboundSession },
|
||||
reply: { dispatchReplyWithBufferedBlockDispatcher },
|
||||
},
|
||||
},
|
||||
deliver,
|
||||
durable: { replyToMode: "first" },
|
||||
onRecordError: vi.fn(),
|
||||
onDispatchError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
ctxPayload,
|
||||
payload: expect.objectContaining({ text: "hello durable" }),
|
||||
info: { kind: "final" },
|
||||
replyToMode: "first",
|
||||
}),
|
||||
);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports shared visible reply dispatch helpers", () => {
|
||||
expect(hasVisibleInboundReplyDispatch(undefined)).toBe(false);
|
||||
expect(
|
||||
@@ -95,4 +238,19 @@ describe("recordInboundSessionAndDispatchReply", () => {
|
||||
final: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes channel-message dispatch names as the canonical helpers for new channel code", () => {
|
||||
expect(createChannelMessageReplyPipeline).toBe(createChannelReplyPipeline);
|
||||
expect(resolveChannelMessageSourceReplyDeliveryMode).toBe(
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
);
|
||||
expect(createChannelMessageReplyPrefixContext).toBe(createReplyPrefixContext);
|
||||
expect(createChannelMessageReplyPrefixOptions).toBe(createReplyPrefixOptions);
|
||||
expect(createChannelMessageTypingCallbacks).toBe(createTypingCallbacks);
|
||||
expect(typeof dispatchChannelMessageReplyWithBase).toBe("function");
|
||||
expect(typeof dispatchInboundReplyWithBase).toBe("function");
|
||||
expect(hasFinalChannelMessageReplyDispatch).toBe(hasFinalInboundReplyDispatch);
|
||||
expect(typeof recordChannelMessageReplyDispatch).toBe("function");
|
||||
expect(typeof recordInboundSessionAndDispatchReply).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,15 +10,24 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
||||
import {
|
||||
hasFinalChannelTurnDispatch,
|
||||
hasVisibleChannelTurnDispatch,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
isDurableInboundReplyDeliveryHandled,
|
||||
resolveChannelTurnDispatchCounts,
|
||||
runChannelTurn,
|
||||
runPreparedChannelTurn,
|
||||
throwIfDurableInboundReplyDeliveryFailed,
|
||||
} from "../channels/turn/kernel.js";
|
||||
import type { DurableInboundReplyDeliveryOptions } from "../channels/turn/kernel.js";
|
||||
import type { PreparedChannelTurn, RunChannelTurnParams } from "../channels/turn/types.js";
|
||||
export type { ChannelTurnRecordOptions } from "../channels/turn/types.js";
|
||||
export type { DurableInboundReplyDeliveryParams } from "../channels/turn/kernel.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
|
||||
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
|
||||
import { createChannelReplyPipeline } from "./channel-reply-core.js";
|
||||
import {
|
||||
normalizeOutboundReplyPayload,
|
||||
type OutboundReplyPayload,
|
||||
type ReplyPayload,
|
||||
} from "./reply-payload.js";
|
||||
|
||||
type ReplyOptionsWithoutModelSelected = Omit<
|
||||
Omit<GetReplyOptions, "onBlockReply">,
|
||||
@@ -45,6 +54,8 @@ export async function runInboundReplyTurn<TRaw, TDispatchResult = DispatchFromCo
|
||||
export {
|
||||
hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch,
|
||||
hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch,
|
||||
deliverInboundReplyWithMessageSendContext as deliverDurableInboundReplyPayload,
|
||||
deliverInboundReplyWithMessageSendContext,
|
||||
resolveChannelTurnDispatchCounts as resolveInboundReplyDispatchCounts,
|
||||
};
|
||||
|
||||
@@ -108,30 +119,7 @@ export function buildInboundReplyDispatchBase(params: {
|
||||
}
|
||||
|
||||
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
|
||||
type RecordInboundSessionAndDispatchReplyParams = Parameters<
|
||||
typeof recordInboundSessionAndDispatchReply
|
||||
>[0];
|
||||
|
||||
/** Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn. */
|
||||
export async function dispatchInboundReplyWithBase(
|
||||
params: BuildInboundReplyDispatchBaseParams &
|
||||
Pick<
|
||||
RecordInboundSessionAndDispatchReplyParams,
|
||||
"deliver" | "onRecordError" | "onDispatchError" | "replyOptions"
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dispatchBase = buildInboundReplyDispatchBase(params);
|
||||
await recordInboundSessionAndDispatchReply({
|
||||
...dispatchBase,
|
||||
deliver: params.deliver,
|
||||
onRecordError: params.onRecordError,
|
||||
onDispatchError: params.onDispatchError,
|
||||
replyOptions: params.replyOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */
|
||||
export async function recordInboundSessionAndDispatchReply(params: {
|
||||
type RecordChannelMessageReplyDispatchParams = {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
@@ -142,17 +130,80 @@ export async function recordInboundSessionAndDispatchReply(params: {
|
||||
recordInboundSession: RecordInboundSessionFn;
|
||||
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
|
||||
deliver: (payload: OutboundReplyPayload) => Promise<void>;
|
||||
durable?: false | DurableInboundReplyDeliveryOptions;
|
||||
onRecordError: (err: unknown) => void;
|
||||
onDispatchError: (err: unknown, info: { kind: string }) => void;
|
||||
replyOptions?: ReplyOptionsWithoutModelSelected;
|
||||
}): Promise<void> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn.
|
||||
*/
|
||||
export async function dispatchChannelMessageReplyWithBase(
|
||||
params: BuildInboundReplyDispatchBaseParams &
|
||||
Pick<
|
||||
RecordChannelMessageReplyDispatchParams,
|
||||
"deliver" | "durable" | "onRecordError" | "onDispatchError" | "replyOptions"
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dispatchBase = buildInboundReplyDispatchBase(params);
|
||||
await recordChannelMessageReplyDispatch({
|
||||
...dispatchBase,
|
||||
deliver: params.deliver,
|
||||
durable: params.durable,
|
||||
onRecordError: params.onRecordError,
|
||||
onDispatchError: params.onDispatchError,
|
||||
replyOptions: params.replyOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn.
|
||||
*
|
||||
* @deprecated Legacy inbound reply helper. New channel plugins should expose a
|
||||
* `message` adapter via `defineChannelMessageAdapter(...)` and use
|
||||
* `dispatchChannelMessageReplyWithBase` only for compatibility dispatchers that
|
||||
* have not moved to the message lifecycle yet.
|
||||
*/
|
||||
export async function dispatchInboundReplyWithBase(
|
||||
params: Parameters<typeof dispatchChannelMessageReplyWithBase>[0],
|
||||
): Promise<void> {
|
||||
await dispatchChannelMessageReplyWithBase(params);
|
||||
}
|
||||
|
||||
/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */
|
||||
export async function recordChannelMessageReplyDispatch(
|
||||
params: RecordChannelMessageReplyDispatchParams,
|
||||
): Promise<void> {
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const deliver = createNormalizedOutboundDeliverer(params.deliver);
|
||||
const deliver = async (payload: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
||||
const normalized =
|
||||
payload && typeof payload === "object"
|
||||
? normalizeOutboundReplyPayload(payload as Record<string, unknown>)
|
||||
: {};
|
||||
if (params.durable) {
|
||||
const durable = await deliverInboundReplyWithMessageSendContext({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
agentId: params.agentId,
|
||||
ctxPayload: params.ctxPayload,
|
||||
payload: normalized as ReplyPayload,
|
||||
info,
|
||||
...params.durable,
|
||||
});
|
||||
throwIfDurableInboundReplyDeliveryFailed(durable);
|
||||
if (isDurableInboundReplyDeliveryHandled(durable)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await params.deliver(normalized);
|
||||
};
|
||||
|
||||
await runPreparedChannelTurn({
|
||||
channel: params.channel,
|
||||
@@ -180,3 +231,22 @@ export async function recordInboundSessionAndDispatchReply(params: {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the inbound session first, then dispatch the reply using normalized outbound delivery.
|
||||
*
|
||||
* @deprecated Legacy inbound reply helper. New channel plugins should expose a
|
||||
* `message` adapter via `defineChannelMessageAdapter(...)` and use
|
||||
* `recordChannelMessageReplyDispatch` only for compatibility dispatchers that
|
||||
* have not moved to the message lifecycle yet.
|
||||
*/
|
||||
export async function recordInboundSessionAndDispatchReply(
|
||||
params: RecordChannelMessageReplyDispatchParams,
|
||||
): Promise<void> {
|
||||
await recordChannelMessageReplyDispatch(params);
|
||||
}
|
||||
|
||||
export const buildChannelMessageReplyDispatchBase = buildInboundReplyDispatchBase;
|
||||
export const hasFinalChannelMessageReplyDispatch = hasFinalChannelTurnDispatch;
|
||||
export const hasVisibleChannelMessageReplyDispatch = hasVisibleChannelTurnDispatch;
|
||||
export const resolveChannelMessageReplyDispatchCounts = resolveChannelTurnDispatchCounts;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user