Fix lint warnings and tighten test mocks
This commit is contained in:
48
src/commands/up.ts
Normal file
48
src/commands/up.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CliDeps, RuntimeEnv } from "../index.js";
|
||||
import { waitForever as defaultWaitForever } from "../index.js";
|
||||
|
||||
export async function upCommand(
|
||||
opts: { port: string; path: string; verbose?: boolean; yes?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await deps.ensureFunnel(port, undefined, runtime);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
|
||||
const server = await deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
);
|
||||
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
}
|
||||
102
src/index.commands.test.ts
Normal file
102
src/index.commands.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { createDefaultDeps, defaultRuntime } from "./index.js";
|
||||
|
||||
vi.mock("twilio", () => {
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
});
|
||||
|
||||
import * as index from "./index.js";
|
||||
import * as provider from "./provider-web.js";
|
||||
|
||||
beforeEach(() => {
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CLI commands", () => {
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
557
src/index.core.test.ts
Normal file
557
src/index.core.test.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||||
import { withWhatsAppPrefix } from "./utils.js";
|
||||
|
||||
// Twilio mock factory shared across tests
|
||||
vi.mock("twilio", () => {
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
});
|
||||
|
||||
type TwilioFactoryMock = ReturnType<typeof createMockTwilio>["factory"];
|
||||
const twilioFactory = (await import("twilio")).default as TwilioFactoryMock;
|
||||
|
||||
import * as index from "./index.js";
|
||||
|
||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
delete process.env.TWILIO_API_KEY;
|
||||
delete process.env.TWILIO_API_SECRET;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.entries(envBackup).forEach(([k, v]) => {
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("command helpers", () => {
|
||||
it("runCommandWithTimeout captures stdout and timeout", async () => {
|
||||
const result = await index.runCommandWithTimeout(
|
||||
[process.execPath, "-e", "console.log('ok')"],
|
||||
500,
|
||||
);
|
||||
expect(result.stdout.trim()).toBe("ok");
|
||||
|
||||
const slow = index.runCommandWithTimeout(
|
||||
[process.execPath, "-e", "setTimeout(()=>{}, 1000)"],
|
||||
20,
|
||||
);
|
||||
const timedOut = await slow;
|
||||
expect(timedOut.killed).toBe(true);
|
||||
});
|
||||
|
||||
it("ensurePortAvailable rejects when in use", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(index.ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
index.PortInUseError,
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config and templating", () => {
|
||||
it("getReplyFromConfig returns text when allowlist passes", async () => {
|
||||
const cfg = {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: {
|
||||
mode: "text" as const,
|
||||
text: "Hello {{From}} {{Body}}",
|
||||
bodyPrefix: "[pfx] ",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const onReplyStart = vi.fn();
|
||||
const result = await index.getReplyFromConfig(
|
||||
{ Body: "hi", From: "whatsapp:+1555", To: "x" },
|
||||
{ onReplyStart },
|
||||
cfg,
|
||||
);
|
||||
expect(result).toBe("Hello whatsapp:+1555 [pfx] hi");
|
||||
expect(onReplyStart).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getReplyFromConfig runs command and manages session store", async () => {
|
||||
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValue("session-123");
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "cmd output\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
const cfg = {
|
||||
inbound: {
|
||||
reply: {
|
||||
mode: "command" as const,
|
||||
command: ["echo", "{{Body}}"],
|
||||
template: "[tmpl]",
|
||||
session: {
|
||||
scope: "per-sender" as const,
|
||||
resetTriggers: ["/new"],
|
||||
store: tmpStore,
|
||||
sessionArgNew: ["--sid", "{{SessionId}}"],
|
||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = await index.getReplyFromConfig(
|
||||
{ Body: "/new hello", From: "+1555", To: "+1666" },
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
expect(first).toBe("cmd output");
|
||||
const argvFirst = runSpy.mock.calls[0][0];
|
||||
expect(argvFirst).toEqual([
|
||||
"echo",
|
||||
"[tmpl]",
|
||||
"--sid",
|
||||
"session-123",
|
||||
"hello",
|
||||
]);
|
||||
|
||||
const second = await index.getReplyFromConfig(
|
||||
{ Body: "next", From: "+1555", To: "+1666" },
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
expect(second).toBe("cmd output");
|
||||
const argvSecond = runSpy.mock.calls[1][0];
|
||||
expect(argvSecond[2]).toBe("--resume");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twilio interactions", () => {
|
||||
it("autoReplyIfConfigured sends message when configured", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
client.messages.create.mockResolvedValue({});
|
||||
await index.autoReplyIfConfigured(
|
||||
client,
|
||||
{
|
||||
from: "whatsapp:+1",
|
||||
to: "whatsapp:+2",
|
||||
body: "hi",
|
||||
sid: "SM1",
|
||||
} as unknown as MessageInstance,
|
||||
{
|
||||
inbound: {
|
||||
reply: { mode: "text", text: "auto-text" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(client.messages.create).toHaveBeenCalledWith({
|
||||
from: "whatsapp:+2",
|
||||
to: "whatsapp:+1",
|
||||
body: "auto-text",
|
||||
});
|
||||
});
|
||||
|
||||
it("sendTypingIndicator skips missing messageSid and sends when present", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
await index.sendTypingIndicator(client, undefined);
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
|
||||
await index.sendTypingIndicator(client, "SM123");
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "post" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sendMessage wraps Twilio client and returns sid", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
client.messages.create.mockResolvedValue({ sid: "SM999" });
|
||||
twilioFactory.mockReturnValue(client);
|
||||
|
||||
const result = await index.sendMessage("+1555", "hi");
|
||||
expect(client.messages.create).toHaveBeenCalledWith({
|
||||
from: withWhatsAppPrefix("whatsapp:+15551234567"),
|
||||
to: withWhatsAppPrefix("+1555"),
|
||||
body: "hi",
|
||||
});
|
||||
expect(result?.sid).toBe("SM999");
|
||||
});
|
||||
|
||||
it("waitForFinalStatus resolves on delivered", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "sent" })
|
||||
.mockResolvedValueOnce({ status: "delivered" });
|
||||
const client = {
|
||||
messages: vi.fn(() => ({ fetch })),
|
||||
};
|
||||
await index.waitForFinalStatus(
|
||||
client as unknown as ReturnType<typeof index.createClient>,
|
||||
"SM1",
|
||||
1,
|
||||
0,
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waitForFinalStatus exits on failure", async () => {
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
const fetch = vi.fn().mockResolvedValue({ status: "failed" });
|
||||
const client = {
|
||||
messages: vi.fn(() => ({ fetch })),
|
||||
};
|
||||
await index
|
||||
.waitForFinalStatus(
|
||||
client as unknown as ReturnType<typeof index.createClient>,
|
||||
"SM2",
|
||||
1,
|
||||
0,
|
||||
runtime,
|
||||
)
|
||||
.catch(() => {});
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook and messaging", () => {
|
||||
it("startWebhook responds and auto-replies", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
client.messages.create.mockResolvedValue({});
|
||||
twilioFactory.mockReturnValue(client);
|
||||
vi.spyOn(index, "getReplyFromConfig").mockResolvedValue("Auto");
|
||||
|
||||
const server = await index.startWebhook(0, "/hook", undefined, false);
|
||||
const address = server.address() as net.AddressInfo;
|
||||
const url = `http://127.0.0.1:${address.port}/hook`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("listRecentMessages merges and sorts", async () => {
|
||||
const inbound = [
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
];
|
||||
const outbound = [
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
];
|
||||
const client = twilioFactory._createClient();
|
||||
client.messages.list
|
||||
.mockResolvedValueOnce(inbound)
|
||||
.mockResolvedValueOnce(outbound);
|
||||
|
||||
const messages = await index.listRecentMessages(60, 5, client);
|
||||
expect(messages[0].sid).toBe("2");
|
||||
expect(messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("formatMessageLine builds readable string", () => {
|
||||
const line = index.formatMessageLine({
|
||||
sid: "SID",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hello world",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(line).toContain("SID");
|
||||
expect(line).toContain("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sender discovery", () => {
|
||||
it("findWhatsappSenderSid prefers explicit env", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
const sid = await index.findWhatsappSenderSid(client, "+1555", "SID123");
|
||||
expect(sid).toBe("SID123");
|
||||
});
|
||||
|
||||
it("findWhatsappSenderSid lists senders when needed", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
client.messaging.v2.channelsSenders.list.mockResolvedValue([
|
||||
{ sender_id: withWhatsAppPrefix("+1555"), sid: "S1" },
|
||||
]);
|
||||
const sid = await index.findWhatsappSenderSid(client, "+1555");
|
||||
expect(sid).toBe("S1");
|
||||
});
|
||||
|
||||
it("updateWebhook uses primary update path", async () => {
|
||||
const fetched = { webhook: { callback_url: "https://cb" } };
|
||||
const client = {
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
messaging: {
|
||||
v2: {
|
||||
channelsSenders: vi.fn(() => ({
|
||||
fetch: vi.fn().mockResolvedValue(fetched),
|
||||
})),
|
||||
},
|
||||
v1: { services: vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() })) },
|
||||
},
|
||||
incomingPhoneNumbers: vi.fn(),
|
||||
} as unknown as ReturnType<typeof index.createClient>;
|
||||
|
||||
await index.updateWebhook(client, "SID", "https://example.com", "POST");
|
||||
expect(client.request).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("infra helpers", () => {
|
||||
it("handlePortError prints owner details", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("exit");
|
||||
}) as () => never);
|
||||
vi.spyOn(index, "describePortOwner").mockResolvedValue("proc listening");
|
||||
await expect(
|
||||
index.handlePortError(new index.PortInUseError(1234), 1234, "Context"),
|
||||
).rejects.toThrow("exit");
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getTailnetHostname prefers DNS then IP", async () => {
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
args?: string[],
|
||||
options?: unknown,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
const exec: ExecFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet." } }),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.1.2.3"] } }),
|
||||
stderr: "",
|
||||
});
|
||||
const dns = await index.getTailnetHostname(exec);
|
||||
expect(dns).toBe("host.tailnet");
|
||||
const ip = await index.getTailnetHostname(exec);
|
||||
expect(ip).toBe("100.1.2.3");
|
||||
});
|
||||
|
||||
it("ensureGoInstalled installs when missing", async () => {
|
||||
const exec = vi
|
||||
.fn<
|
||||
index.CommandArgs | index.CommandArgsWithOptions,
|
||||
Promise<{ stdout: string; stderr: string }>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const prompt = vi.fn<[], Promise<boolean>>().mockResolvedValue(true);
|
||||
await index.ensureGoInstalled(exec, prompt);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||
});
|
||||
|
||||
it("ensureTailscaledInstalled installs when missing", async () => {
|
||||
const exec = vi
|
||||
.fn<
|
||||
index.CommandArgs | index.CommandArgsWithOptions,
|
||||
Promise<{ stdout: string; stderr: string }>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const prompt = vi.fn<[], Promise<boolean>>().mockResolvedValue(true);
|
||||
await index.ensureTailscaledInstalled(exec, prompt);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||
});
|
||||
|
||||
it("ensureFunnel enables funnel when status present", async () => {
|
||||
const exec = vi
|
||||
.fn<
|
||||
index.CommandArgs | index.CommandArgsWithOptions,
|
||||
Promise<{ stdout: string; stderr: string }>
|
||||
>()
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ Enabled: true }),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "ok", stderr: "" });
|
||||
await index.ensureFunnel(8080, exec);
|
||||
expect(exec).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("twilio helpers", () => {
|
||||
it("findIncomingNumberSid and messaging sid helpers", async () => {
|
||||
const client = twilioFactory._createClient();
|
||||
client.incomingPhoneNumbers.list.mockResolvedValue([
|
||||
{ sid: "PN1", messagingServiceSid: "MG1" },
|
||||
]);
|
||||
const sid = await index.findIncomingNumberSid(client);
|
||||
expect(sid).toBe("PN1");
|
||||
const msid = await index.findMessagingServiceSid(client);
|
||||
expect(msid).toBe("MG1");
|
||||
});
|
||||
|
||||
it("setMessagingServiceWebhook updates service", async () => {
|
||||
const updater = { update: vi.fn().mockResolvedValue({}), fetch: vi.fn() };
|
||||
const client = twilioFactory._createClient();
|
||||
client.messaging.v1.services.mockReturnValue(
|
||||
updater as unknown as ReturnType<typeof client.messaging.v1.services>,
|
||||
);
|
||||
client.incomingPhoneNumbers.list.mockResolvedValue([
|
||||
{ messagingServiceSid: "MS1" },
|
||||
]);
|
||||
const updated = await index.setMessagingServiceWebhook(
|
||||
client,
|
||||
"https://x",
|
||||
"POST",
|
||||
);
|
||||
expect(updated).toBe(true);
|
||||
expect(updater.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uniqueBySid and sortByDateDesc de-dupe and order", () => {
|
||||
const messages = [
|
||||
{ sid: "1", dateCreated: new Date("2023-01-01") },
|
||||
{ sid: "1", dateCreated: new Date("2023-01-02") },
|
||||
{ sid: "2", dateCreated: new Date("2024-01-01") },
|
||||
];
|
||||
const unique = index.uniqueBySid(messages);
|
||||
expect(unique).toHaveLength(2);
|
||||
const sorted = index.sortByDateDesc(unique);
|
||||
expect(sorted[0].sid).toBe("2");
|
||||
});
|
||||
|
||||
it("formatTwilioError and logTwilioSendError include details", () => {
|
||||
const runtime: index.RuntimeEnv = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
const errString = index.formatTwilioError({
|
||||
code: 123,
|
||||
status: 400,
|
||||
message: "bad",
|
||||
moreInfo: "link",
|
||||
});
|
||||
expect(errString).toContain("123");
|
||||
index.logTwilioSendError({ response: { body: { x: 1 } } }, "+1", runtime);
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logTwilioSendError handles error without response", () => {
|
||||
const runtime: index.RuntimeEnv = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
index.logTwilioSendError(new Error("oops"), undefined, runtime);
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitoring", () => {
|
||||
it("monitorTwilio polls once and processes inbound", async () => {
|
||||
const client = {
|
||||
messages: {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
{
|
||||
sid: "m1",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date(),
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "hi",
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as ReturnType<typeof index.createClient>;
|
||||
vi.spyOn(index, "getReplyFromConfig").mockResolvedValue(undefined);
|
||||
await index.monitorTwilio(0, 0, client, 1);
|
||||
expect(client.messages.list).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureFunnel failure path exits via runtime", async () => {
|
||||
const runtime: index.RuntimeEnv = {
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
log: console.log,
|
||||
};
|
||||
const exec = vi.fn().mockRejectedValue({ stdout: "Funnel is not enabled" });
|
||||
await index.ensureFunnel(8080, exec, runtime).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("monitorWebProvider triggers replies and stops when asked", async () => {
|
||||
const replySpy = vi.fn();
|
||||
const listenerFactory = vi.fn(
|
||||
async (
|
||||
opts: Parameters<typeof index.monitorWebProvider>[1] extends undefined
|
||||
? never
|
||||
: NonNullable<Parameters<typeof index.monitorWebProvider>[1]>,
|
||||
) => {
|
||||
await opts.onMessage({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "id1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: replySpy,
|
||||
});
|
||||
return { close: vi.fn() };
|
||||
},
|
||||
);
|
||||
const resolver = vi.fn().mockResolvedValue("auto");
|
||||
await index.monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(replySpy).toHaveBeenCalledWith("auto");
|
||||
});
|
||||
});
|
||||
130
src/index.ts
130
src/index.ts
@@ -19,7 +19,10 @@ import JSON5 from "json5";
|
||||
import Twilio from "twilio";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { sendCommand } from "./commands/send.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import { webhookCommand } from "./commands/webhook.js";
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
@@ -37,10 +40,7 @@ import {
|
||||
sendMessageWeb,
|
||||
webAuthExists,
|
||||
} from "./provider-web.js";
|
||||
import { sendCommand } from "./commands/send.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { webhookCommand } from "./commands/webhook.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import type { Provider } from "./utils.js";
|
||||
import {
|
||||
assertProvider,
|
||||
CONFIG_DIR,
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
sleep,
|
||||
withWhatsAppPrefix,
|
||||
} from "./utils.js";
|
||||
import type { Provider } from "./utils.js";
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
|
||||
@@ -227,7 +226,10 @@ const EnvSchema = z
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (!val.TWILIO_AUTH_TOKEN && !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)) {
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
@@ -241,7 +243,9 @@ function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => runtime.error(`- ${iss.message}`));
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
|
||||
@@ -254,10 +258,16 @@ function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
|
||||
const auth: AuthMode =
|
||||
apiKey && apiSecret
|
||||
? { accountSid, apiKey, apiSecret }
|
||||
: { accountSid, authToken: authToken! };
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
return {
|
||||
accountSid,
|
||||
@@ -544,7 +554,8 @@ const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
function resolveStorePath(store?: string) {
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir()));
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
@@ -561,9 +572,16 @@ function loadSessionStore(storePath: string): Record<string, SessionEntry> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async function saveSessionStore(storePath: string, store: Record<string, SessionEntry>) {
|
||||
async function saveSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
) {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.promises.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
@@ -592,11 +610,13 @@ async function getReplyFromConfig(
|
||||
|
||||
// Optional session handling (conversation reuse + /new resets)
|
||||
const sessionCfg = reply?.session;
|
||||
const resetTriggers =
|
||||
sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
|
||||
@@ -651,7 +671,7 @@ async function getReplyFromConfig(
|
||||
: "";
|
||||
const prefixedBody = bodyPrefix
|
||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||
: sessionCtx.BodyStripped ?? sessionCtx.Body;
|
||||
: (sessionCtx.BodyStripped ?? sessionCtx.Body);
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: prefixedBody,
|
||||
@@ -692,13 +712,15 @@ async function getReplyFromConfig(
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
if (reply.session) {
|
||||
const sessionArgList = (isNewSession
|
||||
? reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]
|
||||
: reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt = insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
@@ -710,8 +732,10 @@ async function getReplyFromConfig(
|
||||
logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } =
|
||||
await commandRunner(finalArgv, timeoutMs);
|
||||
const { stdout, stderr, code, signal, killed } = await commandRunner(
|
||||
finalArgv,
|
||||
timeoutMs,
|
||||
);
|
||||
const trimmed = stdout.trim();
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
@@ -784,10 +808,10 @@ async function autoReplyIfConfigured(
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -891,7 +915,7 @@ async function sendMessage(
|
||||
const msg =
|
||||
typeof anyErr?.message === "string"
|
||||
? anyErr.message
|
||||
: (anyErr?.message ?? err);
|
||||
: (anyErr?.message ?? err);
|
||||
const more = anyErr?.moreInfo;
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
@@ -1186,9 +1210,9 @@ async function ensureFunnel(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||
@@ -1499,7 +1523,9 @@ function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
@@ -1618,7 +1644,9 @@ async function monitorWebProvider(
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(`Failed sending web auto-reply to ${msg.from}: ${String(err)}`),
|
||||
danger(
|
||||
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1650,8 +1678,8 @@ async function performSend(
|
||||
provider: Provider;
|
||||
},
|
||||
deps: CliDeps,
|
||||
exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
_exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
@@ -1686,8 +1714,8 @@ async function performSend(
|
||||
async function performStatus(
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
_exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
@@ -1720,8 +1748,8 @@ async function performWebhookSetup(
|
||||
verbose?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
_exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
@@ -1746,8 +1774,8 @@ async function performUp(
|
||||
yes?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
_exitFn: (code: number) => never = defaultRuntime.exit,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
@@ -1932,7 +1960,11 @@ program
|
||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||
.option("-l, --lookback <minutes>", "Initial lookback window for twilio mode", "5")
|
||||
.option(
|
||||
"-l, --lookback <minutes>",
|
||||
"Initial lookback window for twilio mode",
|
||||
"5",
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -1971,7 +2003,9 @@ Examples:
|
||||
return;
|
||||
} catch (err) {
|
||||
if (providerPref === "auto") {
|
||||
defaultRuntime.error(warn("Web session unavailable; falling back to twilio."));
|
||||
defaultRuntime.error(
|
||||
warn("Web session unavailable; falling back to twilio."),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -2127,7 +2161,7 @@ export {
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
type toWhatsappJid,
|
||||
program,
|
||||
};
|
||||
|
||||
|
||||
163
src/provider-web.test.ts
Normal file
163
src/provider-web.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MockBaileysSocket } from "../test/mocks/baileys.js";
|
||||
import { createMockBaileys } from "../test/mocks/baileys.js";
|
||||
|
||||
const LAST_SOCKET_KEY = Symbol.for("warelay:lastSocket");
|
||||
|
||||
vi.mock("baileys", () => {
|
||||
const created = createMockBaileys();
|
||||
(globalThis as Record<PropertyKey, unknown>)[LAST_SOCKET_KEY] =
|
||||
created.lastSocket;
|
||||
return created.mod;
|
||||
});
|
||||
|
||||
function getLastSocket(): MockBaileysSocket {
|
||||
const getter = (globalThis as Record<PropertyKey, unknown>)[LAST_SOCKET_KEY];
|
||||
if (typeof getter === "function")
|
||||
return (getter as () => MockBaileysSocket)();
|
||||
if (!getter) throw new Error("Baileys mock not initialized");
|
||||
throw new Error("Invalid Baileys socket getter");
|
||||
}
|
||||
|
||||
vi.mock("qrcode-terminal", () => ({
|
||||
default: { generate: vi.fn() },
|
||||
generate: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
monitorWebInbox,
|
||||
sendMessageWeb,
|
||||
waitForWaConnection,
|
||||
} from "./provider-web.js";
|
||||
|
||||
const baileys = (await import(
|
||||
"baileys"
|
||||
)) as unknown as typeof import("baileys") & {
|
||||
makeWASocket: ReturnType<typeof vi.fn>;
|
||||
useSingleFileAuthState: ReturnType<typeof vi.fn>;
|
||||
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
|
||||
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("provider-web", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const recreated = createMockBaileys();
|
||||
(globalThis as Record<PropertyKey, unknown>)[LAST_SOCKET_KEY] =
|
||||
recreated.lastSocket;
|
||||
baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket);
|
||||
baileys.useSingleFileAuthState.mockImplementation(
|
||||
recreated.mod.useSingleFileAuthState,
|
||||
);
|
||||
baileys.fetchLatestBaileysVersion.mockImplementation(
|
||||
recreated.mod.fetchLatestBaileysVersion,
|
||||
);
|
||||
baileys.makeCacheableSignalKeyStore.mockImplementation(
|
||||
recreated.mod.makeCacheableSignalKeyStore,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates WA socket with QR handler", async () => {
|
||||
await createWaSocket(true, false);
|
||||
const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>;
|
||||
expect(makeWASocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ printQRInTerminal: false }),
|
||||
);
|
||||
const sock = getLastSocket();
|
||||
const saveCreds = (
|
||||
await baileys.useSingleFileAuthState.mock.results[0].value
|
||||
).saveState;
|
||||
// trigger creds.update listener
|
||||
sock.ev.emit("creds.update", {});
|
||||
expect(saveCreds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for connection open", async () => {
|
||||
const ev = new EventEmitter();
|
||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
||||
typeof baileys.makeWASocket
|
||||
>);
|
||||
ev.emit("connection.update", { connection: "open" });
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when connection closes", async () => {
|
||||
const ev = new EventEmitter();
|
||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
||||
typeof baileys.makeWASocket
|
||||
>);
|
||||
ev.emit("connection.update", {
|
||||
connection: "close",
|
||||
lastDisconnect: new Error("bye"),
|
||||
});
|
||||
await expect(promise).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("sends message via web and closes socket", async () => {
|
||||
await sendMessageWeb("+1555", "hi", { verbose: false });
|
||||
const sock = getLastSocket();
|
||||
expect(sock.sendMessage).toHaveBeenCalled();
|
||||
expect(sock.ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loginWeb waits for connection and closes", async () => {
|
||||
const closeSpy = vi.fn();
|
||||
const ev = new EventEmitter();
|
||||
baileys.makeWASocket.mockImplementation(() => ({
|
||||
ev,
|
||||
ws: { close: closeSpy },
|
||||
sendPresenceUpdate: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
const waiter: typeof waitForWaConnection = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
await loginWeb(false, waiter);
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("monitorWebInbox streams inbound messages", async () => {
|
||||
const onMessage = vi.fn(async (msg) => {
|
||||
await msg.sendComposing();
|
||||
await msg.reply("pong");
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = getLastSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith(
|
||||
"composing",
|
||||
"999@s.whatsapp.net",
|
||||
);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: "pong",
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { proto } from "baileys";
|
||||
import {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
@@ -8,13 +9,16 @@ import {
|
||||
makeWASocket,
|
||||
useSingleFileAuthState,
|
||||
} from "baileys";
|
||||
import type { proto } from "baileys";
|
||||
import pino from "pino";
|
||||
import qrcode from "qrcode-terminal";
|
||||
import { danger, info, logVerbose, success } from "./globals.js";
|
||||
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
||||
|
||||
const WA_WEB_AUTH_FILE = path.join(os.homedir(), ".warelay", "credentials.json");
|
||||
const WA_WEB_AUTH_FILE = path.join(
|
||||
os.homedir(),
|
||||
".warelay",
|
||||
"credentials.json",
|
||||
);
|
||||
|
||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
await ensureDir(path.dirname(WA_WEB_AUTH_FILE));
|
||||
@@ -60,7 +64,9 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
return sock;
|
||||
}
|
||||
|
||||
export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>) {
|
||||
export async function waitForWaConnection(
|
||||
sock: ReturnType<typeof makeWASocket>,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
type OffCapable = {
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
@@ -68,7 +74,9 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
|
||||
const evWithOff = sock.ev as unknown as OffCapable;
|
||||
|
||||
const handler = (...args: unknown[]) => {
|
||||
const update = (args[0] ?? {}) as Partial<import("baileys").ConnectionState>;
|
||||
const update = (args[0] ?? {}) as Partial<
|
||||
import("baileys").ConnectionState
|
||||
>;
|
||||
if (update.connection === "open") {
|
||||
evWithOff.off?.("connection.update", handler);
|
||||
resolve();
|
||||
@@ -99,7 +107,9 @@ export async function sendMessageWeb(
|
||||
}
|
||||
const result = await sock.sendMessage(jid, { text: body });
|
||||
const messageId = result?.key?.id ?? "unknown";
|
||||
console.log(success(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`));
|
||||
console.log(
|
||||
success(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`),
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
sock.ws?.close();
|
||||
@@ -231,7 +241,9 @@ export async function monitorWebInbox(options: {
|
||||
reply,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(danger(`Failed handling inbound web message: ${String(err)}`));
|
||||
console.error(
|
||||
danger(`Failed handling inbound web message: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -254,7 +266,8 @@ function extractText(message: proto.IMessage | undefined): string | undefined {
|
||||
}
|
||||
const extended = message.extendedTextMessage?.text;
|
||||
if (extended?.trim()) return extended.trim();
|
||||
const caption = message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||
const caption =
|
||||
message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||
if (caption?.trim()) return caption.trim();
|
||||
return undefined;
|
||||
}
|
||||
@@ -271,6 +284,7 @@ function formatError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const status = getStatusCode(err);
|
||||
const code = (err as { code?: unknown })?.code;
|
||||
if (status || code) return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
||||
if (status || code)
|
||||
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
53
test/mocks/baileys.ts
Normal file
53
test/mocks/baileys.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type MockBaileysSocket = {
|
||||
ev: import("events").EventEmitter;
|
||||
ws: { close: ReturnType<typeof vi.fn> };
|
||||
sendPresenceUpdate: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
user?: { id?: string };
|
||||
};
|
||||
|
||||
export type MockBaileysModule = {
|
||||
DisconnectReason: { loggedOut: number };
|
||||
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
|
||||
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
|
||||
makeWASocket: ReturnType<typeof vi.fn>;
|
||||
useSingleFileAuthState: ReturnType<typeof vi.fn>;
|
||||
jidToE164?: (jid: string) => string | null;
|
||||
proto?: unknown;
|
||||
};
|
||||
|
||||
export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } {
|
||||
const sockets: MockBaileysSocket[] = [];
|
||||
const makeWASocket = vi.fn((opts: unknown) => {
|
||||
const ev = new (require("events").EventEmitter)();
|
||||
const sock: MockBaileysSocket = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }),
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
setImmediate(() => ev.emit("connection.update", { connection: "open" }));
|
||||
sockets.push(sock);
|
||||
return sock;
|
||||
});
|
||||
|
||||
const mod: MockBaileysModule = {
|
||||
DisconnectReason: { loggedOut: 401 },
|
||||
fetchLatestBaileysVersion: vi.fn().mockResolvedValue({ version: [1, 2, 3] }),
|
||||
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
|
||||
makeWASocket,
|
||||
useSingleFileAuthState: vi.fn(async () => ({
|
||||
state: { creds: {}, keys: {} },
|
||||
saveState: vi.fn(),
|
||||
})),
|
||||
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
|
||||
};
|
||||
|
||||
return {
|
||||
mod,
|
||||
lastSocket: () => sockets[sockets.length - 1]!,
|
||||
};
|
||||
}
|
||||
54
test/mocks/twilio.ts
Normal file
54
test/mocks/twilio.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
type MockFn<T extends (...args: never[]) => unknown> = ReturnType<typeof vi.fn<T>>;
|
||||
|
||||
export type MockTwilioClient = {
|
||||
messages: ((sid?: string) => { fetch: MockFn<() => unknown> }) & {
|
||||
create: MockFn<() => unknown>;
|
||||
list: MockFn<() => unknown>;
|
||||
};
|
||||
request?: MockFn<() => unknown>;
|
||||
messaging?: {
|
||||
v2: { channelsSenders: ((sid?: string) => { fetch: MockFn<() => unknown>; update: MockFn<() => unknown> }) & { list: MockFn<() => unknown> } };
|
||||
v1: { services: MockFn<() => { update: MockFn<() => unknown>; fetch: MockFn<() => unknown> }> };
|
||||
};
|
||||
incomingPhoneNumbers?: ((sid?: string) => { update: MockFn<() => unknown> }) & {
|
||||
list: MockFn<() => unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export function createMockTwilio() {
|
||||
const messages = Object.assign(vi.fn((sid?: string) => ({ fetch: vi.fn() })), {
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
});
|
||||
|
||||
const channelsSenders = Object.assign(
|
||||
vi.fn((sid?: string) => ({ fetch: vi.fn(), update: vi.fn() })),
|
||||
{ list: vi.fn() },
|
||||
);
|
||||
|
||||
const services = vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() }));
|
||||
|
||||
const incomingPhoneNumbers = Object.assign(
|
||||
vi.fn((sid?: string) => ({ update: vi.fn() })),
|
||||
{ list: vi.fn() },
|
||||
);
|
||||
|
||||
const client: MockTwilioClient = {
|
||||
messages,
|
||||
request: vi.fn(),
|
||||
messaging: {
|
||||
v2: { channelsSenders },
|
||||
v1: { services },
|
||||
},
|
||||
incomingPhoneNumbers,
|
||||
};
|
||||
|
||||
const factory = Object.assign(vi.fn(() => client), {
|
||||
_client: client,
|
||||
_createClient: () => client,
|
||||
});
|
||||
|
||||
return { client, factory };
|
||||
}
|
||||
Reference in New Issue
Block a user