Files
openclaw/extensions/sms/src/send.test.ts
2026-08-17 00:56:34 -07:00

557 lines
18 KiB
TypeScript

// Sms tests cover send plugin behavior.
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSmsAccount } from "./accounts.js";
import {
prepareSmsMediaAttempt,
sendPreparedSmsMediaAttempt,
sendSmsTextChunks,
toSmsPlainText,
} from "./send.js";
import type { sendSmsViaTwilio as sendSmsViaTwilioType } from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
type SendModule = typeof import("./send.js");
type SendSmsMediaParams = Parameters<SendModule["prepareSmsMediaAttempt"]>[0] &
Omit<Parameters<SendModule["sendPreparedSmsMediaAttempt"]>[0], "attempt">;
const sendSmsViaTwilio = vi.hoisted(() =>
vi.fn<typeof sendSmsViaTwilioType>(async ({ to, onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
return { sid: `SM-${to}`, to };
}),
);
const hostedMediaMocks = vi.hoisted(() => {
const cleanup = vi.fn(async () => undefined);
return {
cleanup,
prepare: vi.fn(async () => ({
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
cleanup,
})),
};
});
const recordInitialSmsDeliveryResult = vi.hoisted(() => vi.fn(async () => null));
const deliveryWarn = vi.hoisted(() => vi.fn());
vi.mock("./twilio.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./twilio.js")>()),
sendSmsViaTwilio,
}));
vi.mock("./media.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./media.js")>()),
prepareHostedSmsMedia: hostedMediaMocks.prepare,
}));
vi.mock("./delivery-observations.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./delivery-observations.js")>()),
recordInitialSmsDeliveryResult,
}));
vi.mock("./runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./runtime.js")>()),
getSmsRuntime: () => ({
logging: {
getChildLogger: () => ({ warn: deliveryWarn }),
},
}),
}));
beforeEach(() => {
sendSmsViaTwilio.mockReset();
sendSmsViaTwilio.mockImplementation(async ({ to, onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
return { sid: `SM-${to}`, to };
});
hostedMediaMocks.cleanup.mockReset();
hostedMediaMocks.cleanup.mockResolvedValue(undefined);
hostedMediaMocks.prepare.mockReset();
hostedMediaMocks.prepare.mockResolvedValue({
url: "https://gateway.example.com/webhooks/sms/media/abc?token=token",
cleanup: hostedMediaMocks.cleanup,
});
recordInitialSmsDeliveryResult.mockReset();
recordInitialSmsDeliveryResult.mockResolvedValue(null);
deliveryWarn.mockClear();
});
afterEach(() => {
delete process.env.TWILIO_ACCOUNT_SID;
delete process.env.TWILIO_AUTH_TOKEN;
delete process.env.TWILIO_PHONE_NUMBER;
delete process.env.SMS_TEXT_CHUNK_LIMIT;
});
function createAccount(textChunkLimit: number): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit,
};
}
async function sendSmsMedia(params: SendSmsMediaParams) {
const attempt = await prepareSmsMediaAttempt(params);
return await sendPreparedSmsMediaAttempt({
account: params.account,
to: params.to,
attempt,
onPlatformSendDispatch: params.onPlatformSendDispatch,
onDeliveryResult: params.onDeliveryResult,
});
}
describe("sendSmsTextChunks", () => {
it("preserves ambiguous Twilio failures after the dispatch boundary", async () => {
const failure = new Error("Twilio response was lost");
const onPlatformSendDispatch = vi.fn(async () => {});
sendSmsViaTwilio.mockImplementationOnce(async ({ onPlatformSendDispatch: onDispatch }) => {
await onDispatch?.();
throw failure;
});
await expect(
sendSmsTextChunks({
account: createAccount(1500),
to: "+15551234567",
text: "sent or not",
onPlatformSendDispatch,
}),
).rejects.toBe(failure);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it.each(["accepted", "scheduled", "queued"])(
"persists the initial Twilio %s response after sending",
async (status) => {
const account = createAccount(1500);
sendSmsViaTwilio.mockResolvedValueOnce({
sid: `SM-${status}`,
to: "+15551234567",
status,
});
await expect(
sendSmsTextChunks({
account,
to: "+15551234567",
text: "hello",
}),
).resolves.toEqual([
{
sid: `SM-${status}`,
to: "+15551234567",
status,
},
]);
expect(recordInitialSmsDeliveryResult).toHaveBeenCalledWith({
account,
result: {
sid: `SM-${status}`,
to: "+15551234567",
status,
},
});
},
);
it("logs initial-state persistence failure without resending or failing the send", async () => {
sendSmsViaTwilio.mockResolvedValueOnce({
sid: "SM-queued",
to: "+15551234567",
status: "queued",
});
recordInitialSmsDeliveryResult.mockRejectedValueOnce(new Error("sqlite unavailable"));
await expect(
sendSmsTextChunks({
account: createAccount(1500),
to: "+15551234567",
text: "hello",
}),
).resolves.toEqual([
{
sid: "SM-queued",
to: "+15551234567",
status: "queued",
},
]);
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(recordInitialSmsDeliveryResult).toHaveBeenCalledOnce();
expect(deliveryWarn).toHaveBeenCalledWith(
"SMS delivery initial state could not be persisted.",
{
messageSid: "SM-queued",
errorType: "Error",
},
);
});
it("records chunk results independently after one observation write fails", async () => {
sendSmsViaTwilio
.mockResolvedValueOnce({
sid: "SM-1",
to: "+15551234567",
status: "queued",
})
.mockResolvedValueOnce({
sid: "SM-2",
to: "+15551234567",
status: "queued",
});
recordInitialSmsDeliveryResult
.mockRejectedValueOnce(new Error("first write failed"))
.mockResolvedValueOnce(null);
await sendSmsTextChunks({
account: createAccount(5),
to: "+15551234567",
text: "alpha beta",
});
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
expect(recordInitialSmsDeliveryResult).toHaveBeenCalledTimes(2);
expect(deliveryWarn).toHaveBeenCalledOnce();
});
it("does not swallow provider send failures", async () => {
sendSmsViaTwilio.mockRejectedValueOnce(new Error("Twilio unavailable"));
await expect(
sendSmsTextChunks({
account: createAccount(1500),
to: "+15551234567",
text: "hello",
}),
).rejects.toThrow("Twilio unavailable");
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(recordInitialSmsDeliveryResult).not.toHaveBeenCalled();
});
it("splits long SMS text before sending to Twilio", async () => {
await sendSmsTextChunks({
account: createAccount(5),
to: "+15551234567",
text: "alpha beta",
});
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
const texts = sendSmsViaTwilio.mock.calls.map(([call]) => {
if (call.text === undefined) {
throw new Error("test invariant: expected a Twilio text send");
}
return call.text;
});
expect(texts).toEqual(["alpha", " beta"]);
expect(texts.join("")).toBe("alpha beta");
});
it("sends one message when an invalid zero SMS_TEXT_CHUNK_LIMIT falls back to the default limit", async () => {
process.env.TWILIO_ACCOUNT_SID = "AC-env";
process.env.TWILIO_AUTH_TOKEN = "env-token";
process.env.TWILIO_PHONE_NUMBER = "+15557654321";
process.env.SMS_TEXT_CHUNK_LIMIT = "0";
await sendSmsTextChunks({
account: resolveSmsAccount({}),
to: "+15551234567",
text: "alpha beta gamma",
});
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(sendSmsViaTwilio.mock.calls[0]?.[0].text).toBe("alpha beta gamma");
});
it("labels transcript-role headers promoted to an SMS chunk boundary", async () => {
const header = "user[2026-07-02]";
await sendSmsTextChunks({
account: createAccount(60),
to: "+15551234567",
text: `${"x".repeat(50)} ${header} ok`,
});
const texts = sendSmsViaTwilio.mock.calls.map(([call]) => {
if (call.text === undefined) {
throw new Error("test invariant: expected a Twilio text send");
}
return call.text;
});
expect(texts).toContain(`[assistant-authored transcript] ${header} ok`);
expect(texts.every((text) => text.length <= 60)).toBe(true);
});
it("flattens markdown before sending SMS chunks", async () => {
expect(
toSmsPlainText("**Hi** [docs](https://example.com)\n\n```bash\napprove 123\n```\nthere"),
).toBe("Hi docs (https://example.com)\n\napprove 123\nthere");
});
it("labels assistant-authored transcript role headers in plain text", () => {
expect(toSmsPlainText("user[Thu 2026-07-02] question")).toBe(
"[assistant-authored transcript] user[Thu 2026-07-02] question",
);
expect(toSmsPlainText("`user[Thu 2026-07-02] question`")).toBe(
"[assistant-authored transcript] user[Thu 2026-07-02] question",
);
expect(toSmsPlainText("\u00a0user[Thu 2026-07-02] question")).toBe(
"[assistant-authored transcript] user[Thu 2026-07-02] question",
);
expect(toSmsPlainText("- user[Thu 2026-07-02] question")).toBe(
"• [assistant-authored transcript] user[Thu 2026-07-02] question",
);
expect(toSmsPlainText("[user](https://example.com)[Thu 2026-07-02] question")).toBe(
"[assistant-authored transcript] user (https://example.com)[Thu 2026-07-02] question",
);
});
it("strips internal tool-trace banners before sending SMS chunks", async () => {
await sendSmsTextChunks({
account: createAccount(1500),
to: "+15551234567",
text: "**Done.**\n⚠️ 🛠️ `search repos (agent)` failed",
});
expect(sendSmsViaTwilio).toHaveBeenCalledOnce();
expect(sendSmsViaTwilio.mock.calls[0]?.[0].text).toBe("Done.");
});
it("caps configured SMS chunks at Twilio's provider maximum", async () => {
await sendSmsTextChunks({
account: createAccount(5000),
to: "+15551234567",
text: "x".repeat(1601),
});
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
expect(sendSmsViaTwilio.mock.calls.map(([call]) => call.text?.length)).toEqual([1600, 1]);
});
it("preserves accepted SIDs when a later SMS chunk fails", async () => {
const failure = new Error("second chunk failed");
const events: string[] = [];
sendSmsViaTwilio
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
events.push("send:first");
return { sid: "SM-first", to: "+15551234567" };
})
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
events.push("send:second");
throw failure;
});
const onDeliveryResult = vi.fn(async (result) => {
events.push(`delivery:${result.messageId}`);
});
const onPlatformSendDispatch = vi.fn(async () => {
events.push("dispatch");
});
let observed: unknown;
try {
await sendSmsTextChunks({
account: createAccount(5),
to: "+15551234567",
text: "alpha beta",
onPlatformSendDispatch,
onDeliveryResult,
});
} catch (error) {
observed = error;
}
expect(isChannelPartialDeliveryError(observed)).toBe(true);
if (!isChannelPartialDeliveryError(observed)) {
throw observed;
}
expect(observed.deliveryResult).toMatchObject({
messageIds: ["SM-first"],
visibleReplySent: true,
receipt: {
parts: [{ platformMessageId: "SM-first", kind: "text" }],
},
});
expect(onDeliveryResult).toHaveBeenCalledExactlyOnceWith({
channel: "sms",
messageId: "SM-first",
chatId: "+15551234567",
receipt: expect.objectContaining({
platformMessageIds: ["SM-first"],
parts: [expect.objectContaining({ platformMessageId: "SM-first", kind: "text" })],
}),
});
expect(onPlatformSendDispatch).toHaveBeenCalledTimes(2);
expect(events).toEqual([
"dispatch",
"send:first",
"delivery:SM-first",
"dispatch",
"send:second",
]);
});
});
describe("sendSmsMedia", () => {
it("preserves existing pre-dispatch proof from hosted-media staging", async () => {
const rejection = new PlatformMessageNotDispatchedError("unsupported hosted media", {
cause: new Error("unsupported content type"),
retryable: false,
});
hostedMediaMocks.prepare.mockRejectedValueOnce(rejection);
await expect(
sendSmsMedia({
account: createAccount(1500),
to: "+15551234567",
text: "photo",
mediaUrl: "/tmp/photo.jpg",
mediaLocalRoots: ["/tmp"],
}),
).rejects.toBe(rejection);
expect(sendSmsViaTwilio).not.toHaveBeenCalled();
});
it("attaches media only to the first caption chunk and returns every SID in order", async () => {
sendSmsViaTwilio
.mockResolvedValueOnce({ sid: "MM-first", to: "+15551234567" })
.mockResolvedValueOnce({ sid: "SM-second", to: "+15551234567" });
const results = await sendSmsMedia({
account: createAccount(5000),
to: "+15551234567",
text: "x".repeat(1601),
mediaUrl: "/tmp/photo.jpg",
mediaLocalRoots: ["/tmp"],
});
expect(results.map((result) => result.sid)).toEqual(["MM-first", "SM-second"]);
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(1, {
account: createAccount(5000),
to: "+15551234567",
text: "x".repeat(1600),
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
onPlatformSendDispatch: expect.any(Function),
});
expect(sendSmsViaTwilio).toHaveBeenNthCalledWith(2, {
account: createAccount(5000),
to: "+15551234567",
text: "x",
onPlatformSendDispatch: expect.any(Function),
});
expect(recordInitialSmsDeliveryResult).toHaveBeenNthCalledWith(1, {
account: createAccount(5000),
result: { sid: "MM-first", to: "+15551234567" },
});
expect(recordInitialSmsDeliveryResult).toHaveBeenNthCalledWith(2, {
account: createAccount(5000),
result: { sid: "SM-second", to: "+15551234567" },
});
});
it("sends media-only MMS without a Body", async () => {
await sendSmsMedia({
account: createAccount(1500),
to: "+15551234567",
text: " ",
mediaUrl: "/tmp/photo.jpg",
mediaLocalRoots: ["/tmp"],
});
expect(sendSmsViaTwilio).toHaveBeenCalledExactlyOnceWith({
account: createAccount(1500),
to: "+15551234567",
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
onPlatformSendDispatch: expect.any(Function),
});
});
it("preserves the accepted MMS when a later caption chunk fails", async () => {
const failure = new Error("second chunk failed");
const events: string[] = [];
sendSmsViaTwilio
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
events.push("send:first");
return { sid: "MM-first", to: "+15551234567" };
})
.mockImplementationOnce(async ({ onPlatformSendDispatch }) => {
await onPlatformSendDispatch?.();
events.push("send:second");
throw failure;
});
const onDeliveryResult = vi.fn(async (result) => {
events.push(`delivery:${result.messageId}`);
});
const onPlatformSendDispatch = vi.fn(async () => {
events.push("dispatch");
});
let observed: unknown;
try {
await sendSmsMedia({
account: createAccount(5),
to: "+15551234567",
text: "alpha beta gamma",
mediaUrl: "/tmp/photo.jpg",
mediaLocalRoots: ["/tmp"],
onPlatformSendDispatch,
onDeliveryResult,
});
} catch (error) {
observed = error;
}
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
expect(sendSmsViaTwilio.mock.calls[0]?.[0]).toMatchObject({
text: "alpha",
mediaUrls: ["https://gateway.example.com/webhooks/sms/media/abc?token=token"],
});
expect(sendSmsViaTwilio.mock.calls[1]?.[0]).toMatchObject({
text: " beta",
});
expect(sendSmsViaTwilio.mock.calls[1]?.[0]).not.toHaveProperty("mediaUrls");
expect(isChannelPartialDeliveryError(observed)).toBe(true);
if (!isChannelPartialDeliveryError(observed)) {
throw observed;
}
expect(observed.deliveryResult).toMatchObject({
messageIds: ["MM-first"],
visibleReplySent: true,
receipt: {
parts: [{ platformMessageId: "MM-first", kind: "media" }],
},
});
expect(onDeliveryResult).toHaveBeenCalledExactlyOnceWith({
channel: "sms",
messageId: "MM-first",
chatId: "+15551234567",
receipt: expect.objectContaining({
platformMessageIds: ["MM-first"],
parts: [expect.objectContaining({ platformMessageId: "MM-first", kind: "media" })],
}),
});
expect(onPlatformSendDispatch).toHaveBeenCalledTimes(2);
expect(events).toEqual([
"dispatch",
"send:first",
"delivery:MM-first",
"dispatch",
"send:second",
]);
});
});