mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
sendPayload: add chunking, empty-payload guard, and tests
This commit is contained in:
committed by
Peter Steinberger
parent
47ef180fb7
commit
dd3f7d57ee
102
extensions/zalo/src/channel.sendpayload.test.ts
Normal file
102
extensions/zalo/src/channel.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
116
extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
116
extensions/zalouser/src/channel.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
98
src/channels/plugins/outbound/discord.sendpayload.test.ts
Normal file
98
src/channels/plugins/outbound/discord.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
92
src/channels/plugins/outbound/slack.sendpayload.test.ts
Normal file
92
src/channels/plugins/outbound/slack.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
106
src/channels/plugins/outbound/whatsapp.sendpayload.test.ts
Normal file
106
src/channels/plugins/outbound/whatsapp.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user