mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-21 06:51:01 +00:00
refactor: deduplicate reply payload handling
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -71,6 +71,7 @@ export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
resolveOutboundMediaUrls,
|
||||
resolveSendableOutboundReplyParts,
|
||||
sendMediaWithLeadingCaption,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./reply-payload.js";
|
||||
|
||||
Reference in New Issue
Block a user