sendPayload: add chunking, empty-payload guard, and tests

This commit is contained in:
David Friedland
2026-03-01 22:12:02 -08:00
committed by Peter Steinberger
parent 47ef180fb7
commit dd3f7d57ee
12 changed files with 706 additions and 12 deletions

View File

@@ -0,0 +1,102 @@
import type { ReplyPayload } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { zaloPlugin } from "./channel.js";
vi.mock("./send.js", () => ({
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
}));
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "123456789",
text: "",
payload,
};
}
describe("zaloPlugin outbound sendPayload", () => {
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
beforeEach(async () => {
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalo);
mockedSend.mockClear();
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
});
it("text-only delegates to sendText", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
});
it("single media delegates to sendMedia", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
const result = await zaloPlugin.outbound!.sendPayload!(
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
);
expect(mockedSend).toHaveBeenCalledWith(
"123456789",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "zalo" });
});
it("multi-media iterates URLs with caption on first", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
.mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
const result = await zaloPlugin.outbound!.sendPayload!(
baseCtx({
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
}),
);
expect(mockedSend).toHaveBeenCalledTimes(2);
expect(mockedSend).toHaveBeenNthCalledWith(
1,
"123456789",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(mockedSend).toHaveBeenNthCalledWith(
2,
"123456789",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
});
it("empty payload returns no-op", async () => {
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
expect(mockedSend).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "zalo", messageId: "" });
});
it("chunking splits long text", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
.mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
const longText = "a".repeat(3000);
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of mockedSend.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
}
expect(result).toMatchObject({ channel: "zalo" });
});
});

View File

@@ -303,15 +303,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalo", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zaloPlugin.outbound!.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -323,7 +327,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
}
return lastResult;
}
return zaloPlugin.outbound!.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const outbound = zaloPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {

View File

@@ -0,0 +1,116 @@
import type { ReplyPayload } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { zalouserPlugin } from "./channel.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
}));
vi.mock("./accounts.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
resolveZalouserAccountSync: () => ({
accountId: "default",
profile: "default",
name: "test",
enabled: true,
config: {},
}),
};
});
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "987654321",
text: "",
payload,
};
}
describe("zalouserPlugin outbound sendPayload", () => {
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
beforeEach(async () => {
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear();
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
});
it("text-only delegates to sendText", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" });
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" });
});
it("single media delegates to sendMedia", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" });
const result = await zalouserPlugin.outbound!.sendPayload!(
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
);
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "zalouser" });
});
it("multi-media iterates URLs with caption on first", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zlu-1" })
.mockResolvedValueOnce({ ok: true, messageId: "zlu-2" });
const result = await zalouserPlugin.outbound!.sendPayload!(
baseCtx({
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
}),
);
expect(mockedSend).toHaveBeenCalledTimes(2);
expect(mockedSend).toHaveBeenNthCalledWith(
1,
"987654321",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(mockedSend).toHaveBeenNthCalledWith(
2,
"987654321",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" });
});
it("empty payload returns no-op", async () => {
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({}));
expect(mockedSend).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "zalouser", messageId: "" });
});
it("chunking splits long text", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" })
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" });
const longText = "a".repeat(3000);
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of mockedSend.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
}
expect(result).toMatchObject({ channel: "zalouser" });
});
});

View File

@@ -519,15 +519,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalouser", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zalouserPlugin.outbound!.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -539,7 +543,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
}
return lastResult;
}
return zalouserPlugin.outbound!.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const outbound = zalouserPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });

View File

@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { createDirectTextMediaOutbound } from "./direct-text-media.js";
function makeOutbound() {
const sendFn = vi.fn().mockResolvedValue({ messageId: "m1" });
const outbound = createDirectTextMediaOutbound({
channel: "imessage",
resolveSender: () => sendFn,
resolveMaxBytes: () => undefined,
buildTextOptions: (opts) => opts as never,
buildMediaOptions: (opts) => opts as never,
});
return { outbound, sendFn };
}
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "user1",
text: "",
payload,
};
}
describe("createDirectTextMediaOutbound sendPayload", () => {
it("text-only delegates to sendText", async () => {
const { outbound, sendFn } = makeOutbound();
const result = await outbound.sendPayload!(baseCtx({ text: "hello" }));
expect(sendFn).toHaveBeenCalledTimes(1);
expect(sendFn).toHaveBeenCalledWith("user1", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "imessage", messageId: "m1" });
});
it("single media delegates to sendMedia", async () => {
const { outbound, sendFn } = makeOutbound();
const result = await outbound.sendPayload!(
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
);
expect(sendFn).toHaveBeenCalledTimes(1);
expect(sendFn).toHaveBeenCalledWith(
"user1",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "imessage", messageId: "m1" });
});
it("multi-media iterates URLs with caption on first", async () => {
const sendFn = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1" })
.mockResolvedValueOnce({ messageId: "m2" });
const outbound = createDirectTextMediaOutbound({
channel: "imessage",
resolveSender: () => sendFn,
resolveMaxBytes: () => undefined,
buildTextOptions: (opts) => opts as never,
buildMediaOptions: (opts) => opts as never,
});
const result = await outbound.sendPayload!(
baseCtx({
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
}),
);
expect(sendFn).toHaveBeenCalledTimes(2);
expect(sendFn).toHaveBeenNthCalledWith(
1,
"user1",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendFn).toHaveBeenNthCalledWith(
2,
"user1",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "imessage", messageId: "m2" });
});
it("empty payload returns no-op", async () => {
const { outbound, sendFn } = makeOutbound();
const result = await outbound.sendPayload!(baseCtx({}));
expect(sendFn).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "imessage", messageId: "" });
});
it("chunking splits long text", async () => {
const sendFn = vi
.fn()
.mockResolvedValueOnce({ messageId: "c1" })
.mockResolvedValueOnce({ messageId: "c2" });
const outbound = createDirectTextMediaOutbound({
channel: "signal",
resolveSender: () => sendFn,
resolveMaxBytes: () => undefined,
buildTextOptions: (opts) => opts as never,
buildMediaOptions: (opts) => opts as never,
});
// textChunkLimit is 4000; generate text exceeding that
const longText = "a".repeat(5000);
const result = await outbound.sendPayload!(baseCtx({ text: longText }));
expect(sendFn.mock.calls.length).toBeGreaterThanOrEqual(2);
// Each chunk should be within the limit
for (const call of sendFn.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(4000);
}
expect(result).toMatchObject({ channel: "signal" });
});
});

View File

@@ -92,15 +92,19 @@ export function createDirectTextMediaOutbound<
chunkerMode: "text",
textChunkLimit: 4000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: params.channel, messageId: "" };
}
if (urls.length > 0) {
let lastResult = await outbound.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -112,7 +116,13 @@ export function createDirectTextMediaOutbound<
}
return lastResult;
}
return outbound.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
return await sendDirect({

View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { discordOutbound } from "./discord.js";
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "channel:123456",
text: "",
payload,
deps: {
sendDiscord: vi.fn().mockResolvedValue({ messageId: "dc-1", channelId: "123456" }),
},
};
}
describe("discordOutbound sendPayload", () => {
it("text-only delegates to sendText", async () => {
const ctx = baseCtx({ text: "hello" });
const result = await discordOutbound.sendPayload!(ctx);
expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendDiscord).toHaveBeenCalledWith(
"channel:123456",
"hello",
expect.any(Object),
);
expect(result).toMatchObject({ channel: "discord" });
});
it("single media delegates to sendMedia", async () => {
const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" });
const result = await discordOutbound.sendPayload!(ctx);
expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendDiscord).toHaveBeenCalledWith(
"channel:123456",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "discord" });
});
it("multi-media iterates URLs with caption on first", async () => {
const sendDiscord = vi
.fn()
.mockResolvedValueOnce({ messageId: "dc-1", channelId: "123456" })
.mockResolvedValueOnce({ messageId: "dc-2", channelId: "123456" });
const ctx = {
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
} as ReplyPayload,
deps: { sendDiscord },
};
const result = await discordOutbound.sendPayload!(ctx);
expect(sendDiscord).toHaveBeenCalledTimes(2);
expect(sendDiscord).toHaveBeenNthCalledWith(
1,
"channel:123456",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendDiscord).toHaveBeenNthCalledWith(
2,
"channel:123456",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "discord", messageId: "dc-2" });
});
it("empty payload returns no-op", async () => {
const ctx = baseCtx({});
const result = await discordOutbound.sendPayload!(ctx);
expect(ctx.deps.sendDiscord).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "discord", messageId: "" });
});
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
// Discord has chunker: null, so long text should be sent as a single message
const ctx = baseCtx({ text: "a".repeat(3000) });
const result = await discordOutbound.sendPayload!(ctx);
expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendDiscord).toHaveBeenCalledWith(
"channel:123456",
"a".repeat(3000),
expect.any(Object),
);
expect(result).toMatchObject({ channel: "discord" });
});
});

View File

@@ -81,15 +81,19 @@ export const discordOutbound: ChannelOutboundAdapter = {
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "discord", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await discordOutbound.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -101,7 +105,13 @@ export const discordOutbound: ChannelOutboundAdapter = {
}
return lastResult;
}
return discordOutbound.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const limit = discordOutbound.textChunkLimit;
const chunks = limit && discordOutbound.chunker ? discordOutbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof discordOutbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await discordOutbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { slackOutbound } from "./slack.js";
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "C12345",
text: "",
payload,
deps: {
sendSlack: vi
.fn()
.mockResolvedValue({ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }),
},
};
}
describe("slackOutbound sendPayload", () => {
it("text-only delegates to sendText", async () => {
const ctx = baseCtx({ text: "hello" });
const result = await slackOutbound.sendPayload!(ctx);
expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "slack" });
});
it("single media delegates to sendMedia", async () => {
const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" });
const result = await slackOutbound.sendPayload!(ctx);
expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendSlack).toHaveBeenCalledWith(
"C12345",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "slack" });
});
it("multi-media iterates URLs with caption on first", async () => {
const sendSlack = vi
.fn()
.mockResolvedValueOnce({ messageId: "sl-1", channelId: "C12345" })
.mockResolvedValueOnce({ messageId: "sl-2", channelId: "C12345" });
const ctx = {
cfg: {},
to: "C12345",
text: "",
payload: {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
} as ReplyPayload,
deps: { sendSlack },
};
const result = await slackOutbound.sendPayload!(ctx);
expect(sendSlack).toHaveBeenCalledTimes(2);
expect(sendSlack).toHaveBeenNthCalledWith(
1,
"C12345",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendSlack).toHaveBeenNthCalledWith(
2,
"C12345",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "slack", messageId: "sl-2" });
});
it("empty payload returns no-op", async () => {
const ctx = baseCtx({});
const result = await slackOutbound.sendPayload!(ctx);
expect(ctx.deps.sendSlack).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "slack", messageId: "" });
});
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
// Slack has chunker: null, so long text should be sent as a single message
const ctx = baseCtx({ text: "a".repeat(5000) });
const result = await slackOutbound.sendPayload!(ctx);
expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "a".repeat(5000), expect.any(Object));
expect(result).toMatchObject({ channel: "slack" });
});
});

View File

@@ -94,15 +94,19 @@ export const slackOutbound: ChannelOutboundAdapter = {
chunker: null,
textChunkLimit: 4000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "slack", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await slackOutbound.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -114,7 +118,13 @@ export const slackOutbound: ChannelOutboundAdapter = {
}
return lastResult;
}
return slackOutbound.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const limit = slackOutbound.textChunkLimit;
const chunks = limit && slackOutbound.chunker ? slackOutbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof slackOutbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await slackOutbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => {
return await sendSlackOutboundMessage({

View File

@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { whatsappOutbound } from "./whatsapp.js";
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload,
deps: {
sendWhatsApp: vi.fn().mockResolvedValue({ messageId: "wa-1" }),
},
};
}
describe("whatsappOutbound sendPayload", () => {
it("text-only delegates to sendText", async () => {
const ctx = baseCtx({ text: "hello" });
const result = await whatsappOutbound.sendPayload!(ctx);
expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith(
"5511999999999@c.us",
"hello",
expect.any(Object),
);
expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-1" });
});
it("single media delegates to sendMedia", async () => {
const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" });
const result = await whatsappOutbound.sendPayload!(ctx);
expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1);
expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith(
"5511999999999@c.us",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "whatsapp" });
});
it("multi-media iterates URLs with caption on first", async () => {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "wa-1" })
.mockResolvedValueOnce({ messageId: "wa-2" });
const ctx = {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
} as ReplyPayload,
deps: { sendWhatsApp },
};
const result = await whatsappOutbound.sendPayload!(ctx);
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
1,
"5511999999999@c.us",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"5511999999999@c.us",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-2" });
});
it("empty payload returns no-op", async () => {
const ctx = baseCtx({});
const result = await whatsappOutbound.sendPayload!(ctx);
expect(ctx.deps.sendWhatsApp).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
});
it("chunking splits long text", async () => {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "wa-c1" })
.mockResolvedValueOnce({ messageId: "wa-c2" });
const longText = "a".repeat(5000);
const ctx = {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: longText } as ReplyPayload,
deps: { sendWhatsApp },
};
const result = await whatsappOutbound.sendPayload!(ctx);
expect(sendWhatsApp.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of sendWhatsApp.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(4000);
}
expect(result).toMatchObject({ channel: "whatsapp" });
});
});

View File

@@ -13,15 +13,19 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
resolveTarget: ({ to, allowFrom, mode }) =>
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "whatsapp", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await whatsappOutbound.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
@@ -33,7 +37,14 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
}
return lastResult;
}
return whatsappOutbound.sendText!({ ...ctx, text: ctx.payload.text ?? "" });
const limit = whatsappOutbound.textChunkLimit;
const chunks =
limit && whatsappOutbound.chunker ? whatsappOutbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof whatsappOutbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await whatsappOutbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
const send =