From 52e0c8de25e06a0c2a6e8a8b26a625be9d6d8f12 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 00:10:34 +0100 Subject: [PATCH] Fix lint warnings and tighten test mocks --- src/commands/up.ts | 48 ++++ src/index.commands.test.ts | 102 +++++++ src/index.core.test.ts | 557 +++++++++++++++++++++++++++++++++++++ src/index.ts | 130 +++++---- src/provider-web.test.ts | 163 +++++++++++ src/provider-web.ts | 30 +- test/mocks/baileys.ts | 53 ++++ test/mocks/twilio.ts | 54 ++++ 8 files changed, 1081 insertions(+), 56 deletions(-) create mode 100644 src/commands/up.ts create mode 100644 src/index.commands.test.ts create mode 100644 src/index.core.test.ts create mode 100644 src/provider-web.test.ts create mode 100644 test/mocks/baileys.ts create mode 100644 test/mocks/twilio.ts diff --git a/src/commands/up.ts b/src/commands/up.ts new file mode 100644 index 000000000..e04d06745 --- /dev/null +++ b/src/commands/up.ts @@ -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 }; +} diff --git a/src/index.commands.test.ts b/src/index.commands.test.ts new file mode 100644 index 000000000..21b76a4d8 --- /dev/null +++ b/src/index.commands.test.ts @@ -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(); + }); +}); diff --git a/src/index.core.test.ts b/src/index.core.test.ts new file mode 100644 index 000000000..f564683b8 --- /dev/null +++ b/src/index.core.test.ts @@ -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["factory"]; +const twilioFactory = (await import("twilio")).default as TwilioFactoryMock; + +import * as index from "./index.js"; + +const envBackup = { ...process.env } as Record; + +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, + "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, + "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; + + 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>().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>().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, + ); + 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; + 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[1] extends undefined + ? never + : NonNullable[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"); + }); +}); diff --git a/src/index.ts b/src/index.ts index d9077005c..0f75b94da 100644 --- a/src/index.ts +++ b/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 { return {}; } -async function saveSessionStore(storePath: string, store: Record) { +async function saveSessionStore( + storePath: string, + store: Record, +) { 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 ", "auto | web | twilio", "auto") .option("-i, --interval ", "Polling interval for twilio mode", "5") - .option("-l, --lookback ", "Initial lookback window for twilio mode", "5") + .option( + "-l, --lookback ", + "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, }; diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts new file mode 100644 index 000000000..c68776d3b --- /dev/null +++ b/src/provider-web.test.ts @@ -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)[LAST_SOCKET_KEY] = + created.lastSocket; + return created.mod; +}); + +function getLastSocket(): MockBaileysSocket { + const getter = (globalThis as Record)[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; + useSingleFileAuthState: ReturnType; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; +}; + +describe("provider-web", () => { + beforeEach(() => { + vi.clearAllMocks(); + const recreated = createMockBaileys(); + (globalThis as Record)[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; + 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(); + }); +}); diff --git a/src/provider-web.ts b/src/provider-web.ts index ed7635fff..704885a3b 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -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) { +export async function waitForWaConnection( + sock: ReturnType, +) { return new Promise((resolve, reject) => { type OffCapable = { off?: (event: string, listener: (...args: unknown[]) => void) => void; @@ -68,7 +74,9 @@ export async function waitForWaConnection(sock: ReturnType) const evWithOff = sock.ev as unknown as OffCapable; const handler = (...args: unknown[]) => { - const update = (args[0] ?? {}) as Partial; + 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); } diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts new file mode 100644 index 000000000..8bf6c9854 --- /dev/null +++ b/test/mocks/baileys.ts @@ -0,0 +1,53 @@ +import { vi } from "vitest"; + +export type MockBaileysSocket = { + ev: import("events").EventEmitter; + ws: { close: ReturnType }; + sendPresenceUpdate: ReturnType; + sendMessage: ReturnType; + user?: { id?: string }; +}; + +export type MockBaileysModule = { + DisconnectReason: { loggedOut: number }; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; + makeWASocket: ReturnType; + useSingleFileAuthState: ReturnType; + 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]!, + }; +} diff --git a/test/mocks/twilio.ts b/test/mocks/twilio.ts new file mode 100644 index 000000000..8335b7a98 --- /dev/null +++ b/test/mocks/twilio.ts @@ -0,0 +1,54 @@ +import { vi } from "vitest"; + +type MockFn unknown> = ReturnType>; + +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 }; +}