mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 00:41:25 +00:00
fix(whatsapp): allow media-only sends and normalize leading blank payloads (#14408)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -196,6 +196,73 @@ describe("deliverOutboundPayloads", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips leading blank lines for WhatsApp text payloads", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 4000 } },
|
||||
};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "\n\nHello from WhatsApp" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"+1555",
|
||||
"Hello from WhatsApp",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops whitespace-only WhatsApp text payloads when no media is attached", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 4000 } },
|
||||
};
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: " \n\t " }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps WhatsApp media payloads but clears whitespace-only captions", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 4000 } },
|
||||
};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: " \n\t ", mediaUrl: "https://example.com/photo.png" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"+1555",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
verbose: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
|
||||
const chunker = vi.fn((text: string) => (text ? [text] : []));
|
||||
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
|
||||
|
||||
@@ -312,7 +312,31 @@ export async function deliverOutboundPayloads(params: {
|
||||
})),
|
||||
};
|
||||
};
|
||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
|
||||
const normalizeWhatsAppPayload = (payload: ReplyPayload): ReplyPayload | null => {
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const rawText = typeof payload.text === "string" ? payload.text : "";
|
||||
const normalizedText = rawText.replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
if (!normalizedText.trim()) {
|
||||
if (!hasMedia) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: normalizedText,
|
||||
};
|
||||
};
|
||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads).flatMap((payload) => {
|
||||
if (channel !== "whatsapp") {
|
||||
return [payload];
|
||||
}
|
||||
const normalized = normalizeWhatsAppPayload(payload);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
for (const payload of normalizedPayloads) {
|
||||
const payloadSummary: NormalizedOutboundPayload = {
|
||||
text: payload.text ?? "",
|
||||
|
||||
@@ -9,7 +9,11 @@ import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
||||
import { jsonResult } from "../../agents/tools/common.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createIMessageTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
createIMessageTestPlugin,
|
||||
createOutboundTestPlugin,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
@@ -609,6 +613,152 @@ describe("runMessageAction sandboxed media validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction media caption behavior", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("promotes caption to message for media sends when message is empty", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "m1",
|
||||
chatId: "c1",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "testchat",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "testchat",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "t1",
|
||||
chatId: "c1",
|
||||
}),
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cfg = {
|
||||
channels: {
|
||||
testchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "testchat",
|
||||
target: "channel:abc",
|
||||
media: "https://example.com/cat.png",
|
||||
caption: "caption-only text",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(sendMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "caption-only text",
|
||||
mediaUrl: "https://example.com/cat.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction card-only send behavior", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
card: params.card ?? null,
|
||||
message: params.message ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const cardPlugin: ChannelPlugin = {
|
||||
id: "cardchat",
|
||||
meta: {
|
||||
id: "cardchat",
|
||||
label: "Card Chat",
|
||||
selectionLabel: "Card Chat",
|
||||
docsPath: "/channels/cardchat",
|
||||
blurb: "Card-only send test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "cardchat",
|
||||
source: "test",
|
||||
plugin: cardPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
handleAction.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("allows card-only sends without text or media", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
cardchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const card = {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body: [{ type: "TextBlock", text: "Card-only payload" }],
|
||||
};
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "cardchat",
|
||||
target: "channel:test-card",
|
||||
card,
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(result.handledBy).toBe("plugin");
|
||||
expect(handleAction).toHaveBeenCalled();
|
||||
expect(result.payload).toMatchObject({
|
||||
ok: true,
|
||||
card,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction accountId defaults", () => {
|
||||
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
|
||||
const accountPlugin: ChannelPlugin = {
|
||||
|
||||
@@ -745,6 +745,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false });
|
||||
const hasCard = params.card != null && typeof params.card === "object";
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
||||
let message =
|
||||
readStringParam(params, "message", {
|
||||
required: !mediaHint && !hasCard,
|
||||
@@ -753,6 +754,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
if (message.includes("\\n")) {
|
||||
message = message.replaceAll("\\n", "\n");
|
||||
}
|
||||
if (!message.trim() && caption.trim()) {
|
||||
message = caption;
|
||||
}
|
||||
|
||||
const parsed = parseReplyDirectives(message);
|
||||
const mergedMediaUrls: string[] = [];
|
||||
@@ -804,6 +808,16 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
});
|
||||
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
if (channel === "whatsapp") {
|
||||
message = message.replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
if (!message.trim()) {
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
if (!message.trim() && !mediaUrl && mergedMediaUrls.length === 0 && !hasCard) {
|
||||
throw new Error("send requires text or media");
|
||||
}
|
||||
params.message = message;
|
||||
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
|
||||
const bestEffort = readBooleanParam(params, "bestEffort");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user