diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts new file mode 100644 index 00000000000..5bac81dc54e --- /dev/null +++ b/extensions/zalo/src/channel.sendpayload.test.ts @@ -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>; + + 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" }); + }); +}); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 4a37f1a5e0f..1bd9d3a401c 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -303,15 +303,19 @@ export const zaloPlugin: ChannelPlugin = { 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 = { } 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>>; + 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, { diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts new file mode 100644 index 00000000000..07a246b4957 --- /dev/null +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -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; + 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>; + + 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" }); + }); +}); diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 48c49646509..1b711074d65 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -519,15 +519,19 @@ export const zalouserPlugin: ChannelPlugin = { 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 = { } 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>>; + 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 }); diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts new file mode 100644 index 00000000000..0e5c2ba01db --- /dev/null +++ b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts @@ -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" }); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 7b15d34913a..32e4ed5e5aa 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -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>>; + 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({ diff --git a/src/channels/plugins/outbound/discord.sendpayload.test.ts b/src/channels/plugins/outbound/discord.sendpayload.test.ts new file mode 100644 index 00000000000..07c821d6e79 --- /dev/null +++ b/src/channels/plugins/outbound/discord.sendpayload.test.ts @@ -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" }); + }); +}); diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index 891cbf4b432..9c416c590bb 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -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>>; + 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) { diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts new file mode 100644 index 00000000000..c6df264df12 --- /dev/null +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -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" }); + }); +}); diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 2bfc0f0a672..3828eaff3e1 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -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>>; + 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({ diff --git a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts new file mode 100644 index 00000000000..3eb6f7467dc --- /dev/null +++ b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts @@ -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" }); + }); +}); diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index db1c52a0a10..daa47e3324f 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -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>>; + for (const chunk of chunks) { + lastResult = await whatsappOutbound.sendText!({ ...ctx, text: chunk }); + } + return lastResult!; }, sendText: async ({ to, text, accountId, deps, gifPlayback }) => { const send =