refactor: deduplicate reply payload handling

This commit is contained in:
Peter Steinberger
2026-03-18 18:14:36 +00:00
parent 152d179302
commit 62edfdffbd
58 changed files with 704 additions and 450 deletions

View File

@@ -46,7 +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 { resolveOutboundMediaUrls, resolveSendableOutboundReplyParts } from "./reply-payload.js";
export type {
BaseProbeResult,
ChannelDirectoryEntry,

View File

@@ -1,9 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import {
countOutboundMedia,
deliverFormattedTextWithAttachments,
deliverTextOrMediaReply,
hasOutboundMedia,
hasOutboundReplyContent,
hasOutboundText,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
sendPayloadWithChunkedTextAndMedia,
@@ -84,6 +89,102 @@ describe("resolveOutboundMediaUrls", () => {
});
});
describe("countOutboundMedia", () => {
it("counts normalized media entries", () => {
expect(
countOutboundMedia({
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
}),
).toBe(2);
});
it("counts legacy single-media payloads", () => {
expect(
countOutboundMedia({
mediaUrl: "https://example.com/legacy.png",
}),
).toBe(1);
});
});
describe("hasOutboundMedia", () => {
it("reports whether normalized payloads include media", () => {
expect(hasOutboundMedia({ mediaUrls: ["https://example.com/a.png"] })).toBe(true);
expect(hasOutboundMedia({ mediaUrl: "https://example.com/legacy.png" })).toBe(true);
expect(hasOutboundMedia({})).toBe(false);
});
});
describe("hasOutboundText", () => {
it("checks raw text presence by default", () => {
expect(hasOutboundText({ text: "hello" })).toBe(true);
expect(hasOutboundText({ text: " " })).toBe(true);
expect(hasOutboundText({})).toBe(false);
});
it("can trim whitespace-only text", () => {
expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false);
expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true);
});
});
describe("hasOutboundReplyContent", () => {
it("detects text or media content", () => {
expect(hasOutboundReplyContent({ text: "hello" })).toBe(true);
expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true);
expect(hasOutboundReplyContent({})).toBe(false);
});
it("can ignore whitespace-only text unless media exists", () => {
expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false);
expect(
hasOutboundReplyContent(
{ text: " ", mediaUrls: ["https://example.com/a.png"] },
{ trimText: true },
),
).toBe(true);
});
});
describe("resolveSendableOutboundReplyParts", () => {
it("normalizes missing text and trims media urls", () => {
expect(
resolveSendableOutboundReplyParts({
mediaUrls: [" https://example.com/a.png ", " "],
}),
).toEqual({
text: "",
trimmedText: "",
mediaUrls: ["https://example.com/a.png"],
mediaCount: 1,
hasText: false,
hasMedia: true,
hasContent: true,
});
});
it("accepts transformed text overrides", () => {
expect(
resolveSendableOutboundReplyParts(
{
text: "ignored",
},
{
text: " hello ",
},
),
).toEqual({
text: " hello ",
trimmedText: "hello",
mediaUrls: [],
mediaCount: 0,
hasText: true,
hasMedia: false,
hasContent: true,
});
});
});
describe("resolveTextChunksWithFallback", () => {
it("returns existing chunks unchanged", () => {
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
@@ -161,6 +262,26 @@ describe("deliverTextOrMediaReply", () => {
expect(sendText).not.toHaveBeenCalled();
expect(sendMedia).not.toHaveBeenCalled();
});
it("ignores blank media urls before sending", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: "hello", mediaUrls: [" ", " https://a "] },
text: "hello",
sendText,
sendMedia,
}),
).resolves.toBe("media");
expect(sendMedia).toHaveBeenCalledTimes(1);
expect(sendMedia).toHaveBeenCalledWith({
mediaUrl: "https://a",
caption: "hello",
});
});
});
describe("sendMediaWithLeadingCaption", () => {

View File

@@ -5,6 +5,16 @@ export type OutboundReplyPayload = {
replyToId?: string;
};
export type SendableOutboundReplyParts = {
text: string;
trimmedText: string;
mediaUrls: string[];
mediaCount: number;
hasText: boolean;
hasMedia: boolean;
hasContent: boolean;
};
/** Extract the supported outbound reply fields from loose tool or agent payload objects. */
export function normalizeOutboundReplyPayload(
payload: Record<string, unknown>,
@@ -52,6 +62,54 @@ export function resolveOutboundMediaUrls(payload: {
return [];
}
/** Count outbound media items after legacy single-media fallback normalization. */
export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number {
return resolveOutboundMediaUrls(payload).length;
}
/** Check whether an outbound payload includes any media after normalization. */
export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean {
return countOutboundMedia(payload) > 0;
}
/** Check whether an outbound payload includes text, optionally trimming whitespace first. */
export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean {
const text = options?.trim ? payload.text?.trim() : payload.text;
return Boolean(text);
}
/** Check whether an outbound payload includes any sendable text or media. */
export function hasOutboundReplyContent(
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
options?: { trimText?: boolean },
): boolean {
return hasOutboundText(payload, { trim: options?.trimText }) || hasOutboundMedia(payload);
}
/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */
export function resolveSendableOutboundReplyParts(
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
options?: { text?: string },
): SendableOutboundReplyParts {
const text = options?.text ?? payload.text ?? "";
const trimmedText = text.trim();
const mediaUrls = resolveOutboundMediaUrls(payload)
.map((entry) => entry.trim())
.filter(Boolean);
const mediaCount = mediaUrls.length;
const hasText = Boolean(trimmedText);
const hasMedia = mediaCount > 0;
return {
text,
trimmedText,
mediaUrls,
mediaCount,
hasText,
hasMedia,
hasContent: hasText || hasMedia,
};
}
/** 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) {
@@ -188,7 +246,9 @@ export async function deliverTextOrMediaReply(params: {
isFirst: boolean;
}) => Promise<void> | void;
}): Promise<"empty" | "text" | "media"> {
const mediaUrls = resolveOutboundMediaUrls(params.payload);
const { mediaUrls } = resolveSendableOutboundReplyParts(params.payload, {
text: params.text,
});
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls,
caption: params.text,

View File

@@ -98,9 +98,13 @@ describe("plugin-sdk subpath exports", () => {
});
it("exports reply payload helpers from the dedicated subpath", () => {
expect(typeof replyPayloadSdk.countOutboundMedia).toBe("function");
expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function");
expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function");
expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function");
expect(typeof replyPayloadSdk.hasOutboundMedia).toBe("function");
expect(typeof replyPayloadSdk.hasOutboundReplyContent).toBe("function");
expect(typeof replyPayloadSdk.hasOutboundText).toBe("function");
expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function");
expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function");
expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function");

View File

@@ -71,6 +71,7 @@ export {
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
sendMediaWithLeadingCaption,
sendPayloadWithChunkedTextAndMedia,
} from "./reply-payload.js";