refactor: deduplicate reply payload helpers

This commit is contained in:
Peter Steinberger
2026-03-18 17:29:54 +00:00
parent 656679e6e0
commit 8d73bc77fa
67 changed files with 2246 additions and 1366 deletions

View File

@@ -42,6 +42,7 @@ export * from "../channels/plugins/outbound/interactive.js";
export * from "../channels/plugins/pairing-adapters.js";
export * from "../channels/plugins/runtime-forwarders.js";
export * from "../channels/plugins/target-resolvers.js";
export * from "../channels/plugins/threading-helpers.js";
export * from "../channels/plugins/status-issues/shared.js";
export * from "../channels/plugins/whatsapp-heartbeat.js";
export * from "../infra/outbound/send-deps.js";
@@ -49,6 +50,7 @@ export * from "../polls.js";
export * from "../utils/message-channel.js";
export * from "../whatsapp/normalize.js";
export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js";
export * from "./channel-send-result.js";
export * from "./channel-lifecycle.js";
export * from "./directory-runtime.js";
export type {

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
attachChannelToResult,
attachChannelToResults,
buildChannelSendResult,
createAttachedChannelResultAdapter,
createEmptyChannelResult,
createRawChannelSendResultAdapter,
} from "./channel-send-result.js";
describe("attachChannelToResult", () => {
it("preserves the existing result shape and stamps the channel", () => {
expect(
attachChannelToResult("discord", {
messageId: "m1",
ok: true,
extra: "value",
}),
).toEqual({
channel: "discord",
messageId: "m1",
ok: true,
extra: "value",
});
});
});
describe("attachChannelToResults", () => {
it("stamps each result in a list with the shared channel id", () => {
expect(
attachChannelToResults("signal", [
{ messageId: "m1", timestamp: 1 },
{ messageId: "m2", timestamp: 2 },
]),
).toEqual([
{ channel: "signal", messageId: "m1", timestamp: 1 },
{ channel: "signal", messageId: "m2", timestamp: 2 },
]);
});
});
describe("buildChannelSendResult", () => {
it("normalizes raw send results", () => {
const result = buildChannelSendResult("zalo", {
ok: false,
messageId: null,
error: "boom",
});
expect(result.channel).toBe("zalo");
expect(result.ok).toBe(false);
expect(result.messageId).toBe("");
expect(result.error).toEqual(new Error("boom"));
});
});
describe("createEmptyChannelResult", () => {
it("builds an empty outbound result with channel metadata", () => {
expect(createEmptyChannelResult("line", { chatId: "u1" })).toEqual({
channel: "line",
messageId: "",
chatId: "u1",
});
});
});
describe("createAttachedChannelResultAdapter", () => {
it("wraps outbound delivery and poll results", async () => {
const adapter = createAttachedChannelResultAdapter({
channel: "discord",
sendText: async () => ({ messageId: "m1", channelId: "c1" }),
sendMedia: async () => ({ messageId: "m2" }),
sendPoll: async () => ({ messageId: "m3", pollId: "p1" }),
});
await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
channel: "discord",
messageId: "m1",
channelId: "c1",
});
await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
channel: "discord",
messageId: "m2",
});
await expect(
adapter.sendPoll!({
cfg: {} as never,
to: "x",
poll: { question: "t", options: ["a", "b"] },
}),
).resolves.toEqual({
channel: "discord",
messageId: "m3",
pollId: "p1",
});
});
});
describe("createRawChannelSendResultAdapter", () => {
it("normalizes raw send results", async () => {
const adapter = createRawChannelSendResultAdapter({
channel: "zalo",
sendText: async () => ({ ok: true, messageId: "m1" }),
sendMedia: async () => ({ ok: false, error: "boom" }),
});
await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
channel: "zalo",
ok: true,
messageId: "m1",
error: undefined,
});
await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
channel: "zalo",
ok: false,
messageId: "",
error: new Error("boom"),
});
});
});

View File

@@ -1,9 +1,74 @@
import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
export type ChannelSendRawResult = {
ok: boolean;
messageId?: string | null;
error?: string | null;
};
export function attachChannelToResult<T extends object>(channel: string, result: T) {
return {
channel,
...result,
};
}
export function attachChannelToResults<T extends object>(channel: string, results: readonly T[]) {
return results.map((result) => attachChannelToResult(channel, result));
}
export function createEmptyChannelResult(
channel: string,
result: Partial<Omit<OutboundDeliveryResult, "channel" | "messageId">> & {
messageId?: string;
} = {},
): OutboundDeliveryResult {
return attachChannelToResult(channel, {
messageId: "",
...result,
});
}
type MaybePromise<T> = T | Promise<T>;
type SendTextParams = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
type SendMediaParams = Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0];
type SendPollParams = Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0];
export function createAttachedChannelResultAdapter(params: {
channel: string;
sendText?: (ctx: SendTextParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendMedia?: (ctx: SendMediaParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendPoll?: (ctx: SendPollParams) => MaybePromise<Omit<ChannelPollResult, "channel">>;
}): Pick<ChannelOutboundAdapter, "sendText" | "sendMedia" | "sendPoll"> {
return {
sendText: params.sendText
? async (ctx) => attachChannelToResult(params.channel, await params.sendText!(ctx))
: undefined,
sendMedia: params.sendMedia
? async (ctx) => attachChannelToResult(params.channel, await params.sendMedia!(ctx))
: undefined,
sendPoll: params.sendPoll
? async (ctx) => attachChannelToResult(params.channel, await params.sendPoll!(ctx))
: undefined,
};
}
export function createRawChannelSendResultAdapter(params: {
channel: string;
sendText?: (ctx: SendTextParams) => MaybePromise<ChannelSendRawResult>;
sendMedia?: (ctx: SendMediaParams) => MaybePromise<ChannelSendRawResult>;
}): Pick<ChannelOutboundAdapter, "sendText" | "sendMedia"> {
return {
sendText: params.sendText
? async (ctx) => buildChannelSendResult(params.channel, await params.sendText!(ctx))
: undefined,
sendMedia: params.sendMedia
? async (ctx) => buildChannelSendResult(params.channel, await params.sendMedia!(ctx))
: undefined,
};
}
/** Normalize raw channel send results into the shape shared outbound callers expect. */
export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) {
return {

View File

@@ -1,4 +1,5 @@
import type { DiscordSendResult } from "../../extensions/discord/api.js";
import { attachChannelToResult } from "./channel-send-result.js";
type DiscordSendOptionInput = {
replyToId?: string | null;
@@ -32,5 +33,5 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput)
/** Stamp raw Discord send results with the channel id expected by shared outbound flows. */
export function tagDiscordChannelResult(result: DiscordSendResult) {
return { channel: "discord" as const, ...result };
return attachChannelToResult("discord", result);
}

View File

@@ -76,6 +76,7 @@ export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
createNormalizedOutboundDeliverer,
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "./reply-payload.js";

View File

@@ -46,6 +46,7 @@ export {
splitSetupEntries,
} from "../channels/plugins/setup-wizard-helpers.js";
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
export { resolveOutboundMediaUrls } from "./reply-payload.js";
export type {
BaseProbeResult,
ChannelDirectoryEntry,

View File

@@ -94,6 +94,7 @@ export { createPersistentDedupe } from "./persistent-dedupe.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
createNormalizedOutboundDeliverer,
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "./reply-payload.js";

View File

@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js";
import { describe, expect, it, vi } from "vitest";
import {
deliverFormattedTextWithAttachments,
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
sendPayloadWithChunkedTextAndMedia,
} from "./reply-payload.js";
describe("sendPayloadWithChunkedTextAndMedia", () => {
it("returns empty result when payload has no text and no media", async () => {
@@ -56,3 +64,155 @@ describe("sendPayloadWithChunkedTextAndMedia", () => {
expect(isNumericTargetId("")).toBe(false);
});
});
describe("resolveOutboundMediaUrls", () => {
it("prefers mediaUrls over the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
});
it("falls back to the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/legacy.png"]);
});
});
describe("resolveTextChunksWithFallback", () => {
it("returns existing chunks unchanged", () => {
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
});
it("falls back to the full text when chunkers return nothing", () => {
expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]);
});
it("returns empty for empty text with no chunks", () => {
expect(resolveTextChunksWithFallback("", [])).toEqual([]);
});
});
describe("deliverTextOrMediaReply", () => {
it("sends media first with caption only on the first attachment", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: "hello", mediaUrls: ["https://a", "https://b"] },
text: "hello",
sendText,
sendMedia,
}),
).resolves.toBe("media");
expect(sendMedia).toHaveBeenNthCalledWith(1, {
mediaUrl: "https://a",
caption: "hello",
});
expect(sendMedia).toHaveBeenNthCalledWith(2, {
mediaUrl: "https://b",
caption: undefined,
});
expect(sendText).not.toHaveBeenCalled();
});
it("falls back to chunked text delivery when there is no media", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: "alpha beta gamma" },
text: "alpha beta gamma",
chunkText: () => ["alpha", "beta", "gamma"],
sendText,
sendMedia,
}),
).resolves.toBe("text");
expect(sendText).toHaveBeenCalledTimes(3);
expect(sendText).toHaveBeenNthCalledWith(1, "alpha");
expect(sendText).toHaveBeenNthCalledWith(2, "beta");
expect(sendText).toHaveBeenNthCalledWith(3, "gamma");
expect(sendMedia).not.toHaveBeenCalled();
});
it("returns empty when chunking produces no sendable text", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: " " },
text: " ",
chunkText: () => [],
sendText,
sendMedia,
}),
).resolves.toBe("empty");
expect(sendText).not.toHaveBeenCalled();
expect(sendMedia).not.toHaveBeenCalled();
});
});
describe("sendMediaWithLeadingCaption", () => {
it("passes leading-caption metadata to async error handlers", async () => {
const send = vi
.fn<({ mediaUrl, caption }: { mediaUrl: string; caption?: string }) => Promise<void>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(undefined);
const onError = vi.fn(async () => undefined);
await expect(
sendMediaWithLeadingCaption({
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
caption: "hello",
send,
onError,
}),
).resolves.toBe(true);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
caption: "hello",
index: 0,
isFirst: true,
}),
);
expect(send).toHaveBeenNthCalledWith(2, {
mediaUrl: "https://example.com/b.png",
caption: undefined,
});
});
});
describe("deliverFormattedTextWithAttachments", () => {
it("combines attachment links and forwards replyToId", async () => {
const send = vi.fn(async () => undefined);
await expect(
deliverFormattedTextWithAttachments({
payload: {
text: "hello",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
replyToId: "r1",
},
send,
}),
).resolves.toBe(true);
expect(send).toHaveBeenCalledWith({
text: "hello\n\nAttachment: https://example.com/a.png\nAttachment: https://example.com/b.png",
replyToId: "r1",
});
});
});

View File

@@ -52,6 +52,17 @@ export function resolveOutboundMediaUrls(payload: {
return [];
}
/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */
export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] {
if (chunks.length > 0) {
return [...chunks];
}
if (!text) {
return [];
}
return [text];
}
/** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */
export async function sendPayloadWithChunkedTextAndMedia<
TContext extends { payload: object },
@@ -129,21 +140,32 @@ export async function sendMediaWithLeadingCaption(params: {
mediaUrls: string[];
caption: string;
send: (payload: { mediaUrl: string; caption?: string }) => Promise<void>;
onError?: (error: unknown, mediaUrl: string) => void;
onError?: (params: {
error: unknown;
mediaUrl: string;
caption?: string;
index: number;
isFirst: boolean;
}) => Promise<void> | void;
}): Promise<boolean> {
if (params.mediaUrls.length === 0) {
return false;
}
let first = true;
for (const mediaUrl of params.mediaUrls) {
const caption = first ? params.caption : undefined;
first = false;
for (const [index, mediaUrl] of params.mediaUrls.entries()) {
const isFirst = index === 0;
const caption = isFirst ? params.caption : undefined;
try {
await params.send({ mediaUrl, caption });
} catch (error) {
if (params.onError) {
params.onError(error, mediaUrl);
await params.onError({
error,
mediaUrl,
caption,
index,
isFirst,
});
continue;
}
throw error;
@@ -151,3 +173,60 @@ export async function sendMediaWithLeadingCaption(params: {
}
return true;
}
export async function deliverTextOrMediaReply(params: {
payload: OutboundReplyPayload;
text: string;
chunkText?: (text: string) => readonly string[];
sendText: (text: string) => Promise<void>;
sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise<void>;
onMediaError?: (params: {
error: unknown;
mediaUrl: string;
caption?: string;
index: number;
isFirst: boolean;
}) => Promise<void> | void;
}): Promise<"empty" | "text" | "media"> {
const mediaUrls = resolveOutboundMediaUrls(params.payload);
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls,
caption: params.text,
send: params.sendMedia,
onError: params.onMediaError,
});
if (sentMedia) {
return "media";
}
if (!params.text) {
return "empty";
}
const chunks = params.chunkText ? params.chunkText(params.text) : [params.text];
let sentText = false;
for (const chunk of chunks) {
if (!chunk) {
continue;
}
await params.sendText(chunk);
sentText = true;
}
return sentText ? "text" : "empty";
}
export async function deliverFormattedTextWithAttachments(params: {
payload: OutboundReplyPayload;
send: (params: { text: string; replyToId?: string }) => Promise<void>;
}): Promise<boolean> {
const text = formatTextWithAttachmentLinks(
params.payload.text,
resolveOutboundMediaUrls(params.payload),
);
if (!text) {
return false;
}
await params.send({
text,
replyToId: params.payload.replyToId,
});
return true;
}

View File

@@ -1,4 +1,5 @@
import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime";
import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result";
import * as compatSdk from "openclaw/plugin-sdk/compat";
import * as coreSdk from "openclaw/plugin-sdk/core";
import type {
@@ -16,6 +17,7 @@ import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as nostrSdk from "openclaw/plugin-sdk/nostr";
import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup";
import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup";
import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload";
import * as routingSdk from "openclaw/plugin-sdk/routing";
import * as runtimeSdk from "openclaw/plugin-sdk/runtime";
import * as sandboxSdk from "openclaw/plugin-sdk/sandbox";
@@ -93,6 +95,16 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function");
});
it("exports reply payload helpers from the dedicated subpath", () => {
expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function");
expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function");
expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function");
expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function");
expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function");
expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function");
expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function");
});
it("exports account helper builders from the dedicated subpath", () => {
expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function");
});
@@ -122,17 +134,36 @@ describe("plugin-sdk subpath exports", () => {
});
it("exports channel runtime helpers from the dedicated subpath", () => {
expect(typeof channelRuntimeSdk.attachChannelToResult).toBe("function");
expect(typeof channelRuntimeSdk.attachChannelToResults).toBe("function");
expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function");
expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function");
expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function");
expect(typeof channelRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createStaticReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function");
expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceAndFinalize).toBe("function");
expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function");
expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function");
expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function");
});
it("exports channel send-result helpers from the dedicated subpath", () => {
expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function");
expect(typeof channelSendResultSdk.attachChannelToResults).toBe("function");
expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function");
expect(typeof channelSendResultSdk.createAttachedChannelResultAdapter).toBe("function");
expect(typeof channelSendResultSdk.createEmptyChannelResult).toBe("function");
expect(typeof channelSendResultSdk.createRawChannelSendResultAdapter).toBe("function");
});
it("exports provider setup helpers from the dedicated subpath", () => {
expect(typeof providerSetupSdk.buildVllmProvider).toBe("function");
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");

View File

@@ -77,6 +77,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { buildChannelSendResult } from "./channel-send-result.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,

View File

@@ -68,6 +68,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { buildChannelSendResult } from "./channel-send-result.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,