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

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import {
sendPayloadMediaSequenceAndFinalize,
sendPayloadMediaSequenceOrFallback,
} from "./direct-text-media.js";
describe("sendPayloadMediaSequenceOrFallback", () => {
it("uses the no-media sender when no media entries exist", async () => {
const send = vi.fn();
const sendNoMedia = vi.fn(async () => ({ messageId: "text-1" }));
await expect(
sendPayloadMediaSequenceOrFallback({
text: "hello",
mediaUrls: [],
send,
sendNoMedia,
fallbackResult: { messageId: "" },
}),
).resolves.toEqual({ messageId: "text-1" });
expect(send).not.toHaveBeenCalled();
expect(sendNoMedia).toHaveBeenCalledOnce();
});
it("returns the last media send result and clears text after the first media", async () => {
const calls: Array<{ text: string; mediaUrl: string; isFirst: boolean }> = [];
await expect(
sendPayloadMediaSequenceOrFallback({
text: "caption",
mediaUrls: ["a", "b"],
send: async ({ text, mediaUrl, isFirst }) => {
calls.push({ text, mediaUrl, isFirst });
return { messageId: mediaUrl };
},
fallbackResult: { messageId: "" },
}),
).resolves.toEqual({ messageId: "b" });
expect(calls).toEqual([
{ text: "caption", mediaUrl: "a", isFirst: true },
{ text: "", mediaUrl: "b", isFirst: false },
]);
});
});
describe("sendPayloadMediaSequenceAndFinalize", () => {
it("skips media sends and finalizes directly when no media entries exist", async () => {
const send = vi.fn();
const finalize = vi.fn(async () => ({ messageId: "final-1" }));
await expect(
sendPayloadMediaSequenceAndFinalize({
text: "hello",
mediaUrls: [],
send,
finalize,
}),
).resolves.toEqual({ messageId: "final-1" });
expect(send).not.toHaveBeenCalled();
expect(finalize).toHaveBeenCalledOnce();
});
it("sends the media sequence before the finalizing send", async () => {
const send = vi.fn(async ({ mediaUrl }: { mediaUrl: string }) => ({ messageId: mediaUrl }));
const finalize = vi.fn(async () => ({ messageId: "final-2" }));
await expect(
sendPayloadMediaSequenceAndFinalize({
text: "",
mediaUrls: ["a", "b"],
send,
finalize,
}),
).resolves.toEqual({ messageId: "final-2" });
expect(send).toHaveBeenCalledTimes(2);
expect(finalize).toHaveBeenCalledOnce();
});
});

View File

@@ -58,6 +58,41 @@ export async function sendPayloadMediaSequence<TResult>(params: {
return lastResult;
}
export async function sendPayloadMediaSequenceOrFallback<TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TResult>;
fallbackResult: TResult;
sendNoMedia?: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length === 0) {
return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult;
}
return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult;
}
export async function sendPayloadMediaSequenceAndFinalize<TMediaResult, TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TMediaResult>;
finalize: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length > 0) {
await sendPayloadMediaSequence(params);
}
return await params.finalize();
}
export async function sendTextMediaPayload(params: {
channel: string;
ctx: SendPayloadContext;

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
createScopedAccountReplyToModeResolver,
createStaticReplyToModeResolver,
createTopLevelChannelReplyToModeResolver,
} from "./threading-helpers.js";
describe("createStaticReplyToModeResolver", () => {
it("always returns the configured mode", () => {
expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off");
expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all");
});
});
describe("createTopLevelChannelReplyToModeResolver", () => {
it("reads the top-level channel config", () => {
const resolver = createTopLevelChannelReplyToModeResolver("discord");
expect(
resolver({
cfg: { channels: { discord: { replyToMode: "first" } } } as OpenClawConfig,
}),
).toBe("first");
});
it("falls back to off", () => {
const resolver = createTopLevelChannelReplyToModeResolver("discord");
expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off");
});
});
describe("createScopedAccountReplyToModeResolver", () => {
it("reads the scoped account reply mode", () => {
const resolver = createScopedAccountReplyToModeResolver({
resolveAccount: (cfg, accountId) =>
((
cfg.channels as {
matrix?: { accounts?: Record<string, { replyToMode?: "off" | "first" | "all" }> };
}
).matrix?.accounts?.[accountId?.toLowerCase() ?? "default"] ?? {}) as {
replyToMode?: "off" | "first" | "all";
},
resolveReplyToMode: (account) => account.replyToMode,
});
const cfg = {
channels: {
matrix: {
accounts: {
assistant: { replyToMode: "all" },
},
},
},
} as OpenClawConfig;
expect(resolver({ cfg, accountId: "assistant" })).toBe("all");
expect(resolver({ cfg, accountId: "default" })).toBe("off");
});
it("passes chatType through", () => {
const seen: Array<string | null | undefined> = [];
const resolver = createScopedAccountReplyToModeResolver({
resolveAccount: () => ({ replyToMode: "first" as const }),
resolveReplyToMode: (account, chatType) => {
seen.push(chatType);
return account.replyToMode;
},
});
expect(resolver({ cfg: {} as OpenClawConfig, chatType: "group" })).toBe("first");
expect(seen).toEqual(["group"]);
});
});

View File

@@ -0,0 +1,32 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.base.js";
import type { ChannelThreadingAdapter } from "./types.core.js";
type ReplyToModeResolver = NonNullable<ChannelThreadingAdapter["resolveReplyToMode"]>;
export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver {
return () => mode;
}
export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver {
return ({ cfg }) => {
const channelConfig = (
cfg.channels as Record<string, { replyToMode?: ReplyToMode }> | undefined
)?.[channelId];
return channelConfig?.replyToMode ?? "off";
};
}
export function createScopedAccountReplyToModeResolver<TAccount>(params: {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount;
resolveReplyToMode: (
account: TAccount,
chatType?: string | null,
) => ReplyToMode | null | undefined;
fallback?: ReplyToMode;
}): ReplyToModeResolver {
return ({ cfg, accountId, chatType }) =>
params.resolveReplyToMode(params.resolveAccount(cfg, accountId), chatType) ??
params.fallback ??
"off";
}

View File

@@ -1,4 +1,5 @@
import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js";
import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js";
import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
import { escapeRegExp } from "../../utils.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
@@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget,
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = normalizeText(text);
if (skipEmptyText && !normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
gifPlayback,
}) => {
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizeText(text), {
verbose: false,
...createAttachedChannelResultAdapter({
channel: "whatsapp",
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = normalizeText(text);
if (skipEmptyText && !normalizedText) {
return { messageId: "" };
}
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
return await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
accountId,
deps,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}) => {
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
return await send(to, normalizeText(text), {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}),
};
}

View File

@@ -13,6 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
import { normalizePollInput } from "../../polls.js";
import {
ErrorCodes,
@@ -210,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = {
.map((payload) => payload.text)
.filter(Boolean)
.join("\n");
const mirrorMediaUrls = mirrorPayloads.flatMap(
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
const mirrorMediaUrls = mirrorPayloads.flatMap((payload) =>
resolveOutboundMediaUrls(payload),
);
const providedSessionKey =
typeof request.sessionKey === "string" && request.sessionKey.trim()

View File

@@ -26,6 +26,10 @@ import {
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import {
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,
} from "../../plugin-sdk/reply-payload.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { throwIfAborted } from "./abort.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
@@ -338,7 +342,7 @@ function normalizePayloadsForChannelDelivery(
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
return {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
mediaUrls: resolveOutboundMediaUrls(payload),
interactive: payload.interactive,
channelData: payload.channelData,
};
@@ -721,22 +725,27 @@ async function deliverOutboundPayloadsCore(
continue;
}
let first = true;
let lastMessageId: string | undefined;
for (const url of payloadSummary.mediaUrls) {
throwIfAborted(abortSignal);
const caption = first ? payloadSummary.text : "";
first = false;
if (handler.sendFormattedMedia) {
const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides);
await sendMediaWithLeadingCaption({
mediaUrls: payloadSummary.mediaUrls,
caption: payloadSummary.text,
send: async ({ mediaUrl, caption }) => {
throwIfAborted(abortSignal);
if (handler.sendFormattedMedia) {
const delivery = await handler.sendFormattedMedia(
caption ?? "",
mediaUrl,
sendOverrides,
);
results.push(delivery);
lastMessageId = delivery.messageId;
return;
}
const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides);
results.push(delivery);
lastMessageId = delivery.messageId;
} else {
const delivery = await handler.sendMedia(caption, url, sendOverrides);
results.push(delivery);
lastMessageId = delivery.messageId;
}
}
},
});
emitMessageSent({
success: true,
content: payloadSummary.text,

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
import type { PollInput } from "../../polls.js";
import { normalizePollInput } from "../../polls.js";
import {
@@ -202,8 +203,8 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
.map((payload) => payload.text)
.filter(Boolean)
.join("\n");
const mirrorMediaUrls = normalizedPayloads.flatMap(
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
const mirrorMediaUrls = normalizedPayloads.flatMap((payload) =>
resolveOutboundMediaUrls(payload),
);
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;

View File

@@ -11,6 +11,7 @@ import {
hasReplyContent,
type InteractiveReply,
} from "../../interactive/payload.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
export type NormalizedOutboundPayload = {
text: string;
@@ -96,7 +97,7 @@ export function normalizeOutboundPayloads(
): NormalizedOutboundPayload[] {
const normalizedPayloads: NormalizedOutboundPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const mediaUrls = resolveOutboundMediaUrls(payload);
const interactive = payload.interactive;
const channelData = payload.channelData;
const hasChannelData = hasReplyChannelData(channelData);
@@ -127,10 +128,11 @@ export function normalizeOutboundPayloadsForJson(
): OutboundPayloadJson[] {
const normalized: OutboundPayloadJson[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = resolveOutboundMediaUrls(payload);
normalized.push({
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
interactive: payload.interactive,
channelData: payload.channelData,
});

View File

@@ -1,5 +1,6 @@
import type { messagingApi } from "@line/bot-sdk";
import type { ReplyPayload } from "../auto-reply/types.js";
import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js";
import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js";
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
@@ -123,7 +124,7 @@ export async function deliverLineAutoReply(params: {
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const mediaUrls = resolveOutboundMediaUrls(payload);
const mediaMessages = mediaUrls
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url))

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,