From e8b0b871d53e50a907d1be4b6642cc020d4f02e8 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 1 Aug 2026 17:54:57 -0700 Subject: [PATCH] feat(notifications): Baileys wa-bridge delivery for WhatsApp invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No env has a Twilio WhatsApp sender provisioned (TWILIO_WHATSAPP_FROM is unset in TEST and PROD — only the Verify service is wired), so WhatsApp invite delivery has never worked. Add an env-switched provider: when WA_BRIDGE_URL + WA_BRIDGE_SECRET are set, WhatsApp messages go through the flash-support-infra wa-bridge's authed POST /send (Baileys) as plain text with the invite link (no Meta template needed); otherwise the existing Twilio template path is used unchanged. 15s timeout, message content never logged (invite links carry one-time tokens). Intended use: TEST/dev delivery now (bridge endpoint deployed + smoke-tested on pulse-server). Prod should provision the official Twilio WhatsApp sender + approved template before launch, or deliberately opt into the bridge. Tests: bridge transport (auth header, payload shape, non-ok, network error, unconfigured fallback proves the bridge is the succeeding path) + invite body selection (plain text w/ link vs template blob). 4+2 new, suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg --- src/services/notification/index.ts | 50 ++++++++++++ src/services/notifications/invite.ts | 21 +++-- .../services/notification/wa-bridge.spec.ts | 81 +++++++++++++++++++ .../notifications/invite-body.spec.ts | 58 +++++++++++++ 4 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 test/flash/unit/services/notification/wa-bridge.spec.ts create mode 100644 test/flash/unit/services/notifications/invite-body.spec.ts diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts index 1ef4b1fcf..f512f70ff 100644 --- a/src/services/notification/index.ts +++ b/src/services/notification/index.ts @@ -148,6 +148,19 @@ class NotificationServiceImpl implements NotificationService { } private async sendWhatsApp(to: string, body: string): Promise { + // Baileys bridge delivery (TEST/dev): when WA_BRIDGE_URL + WA_BRIDGE_SECRET + // are configured, WhatsApp messages go out through the wa-bridge's authed + // POST /send instead of Twilio (no Twilio WhatsApp sender is provisioned + // in any env today). Unset both to use the Twilio template path. + if (process.env.WA_BRIDGE_URL && process.env.WA_BRIDGE_SECRET) { + return this.sendWhatsAppViaBridge( + process.env.WA_BRIDGE_URL, + process.env.WA_BRIDGE_SECRET, + to, + body, + ) + } + if (!this.twilioClient) { baseLogger.error("Twilio client not configured") return false @@ -250,6 +263,43 @@ class NotificationServiceImpl implements NotificationService { return false } } + + // Deliver a plain-text WhatsApp message through the Baileys wa-bridge + // (flash-support-infra/services/wa-bridge, authed POST /send). Message + // content is never logged — invite links carry one-time tokens. + private async sendWhatsAppViaBridge( + url: string, + secret: string, + to: string, + body: string, + ): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + try { + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Send-Token": secret }, + body: JSON.stringify({ to, text: body }), + signal: controller.signal, + }) + const payload = (await resp.json().catch(() => ({}))) as { ok?: boolean } + const ok = resp.ok && payload.ok === true + if (ok) { + baseLogger.info( + { to, bodyLength: body.length }, + "WhatsApp message sent via wa-bridge", + ) + } else { + baseLogger.error({ to, status: resp.status }, "wa-bridge send failed") + } + return ok + } catch (error) { + baseLogger.error({ error: String(error), to }, "wa-bridge send error") + return false + } finally { + clearTimeout(timeout) + } + } } export const notificationService = new NotificationServiceImpl() diff --git a/src/services/notifications/invite.ts b/src/services/notifications/invite.ts index cbcb12a74..d8b4ef32a 100644 --- a/src/services/notifications/invite.ts +++ b/src/services/notifications/invite.ts @@ -61,14 +61,19 @@ export const sendInviteNotification = async ({ break case InviteMethod.WHATSAPP: - // For WhatsApp templates (if using approved templates) - messageBody = JSON.stringify({ - templateName: "flash_invite", - templateVariables: { - "1": senderName, - "2": token, - }, - }) + if (process.env.WA_BRIDGE_URL && process.env.WA_BRIDGE_SECRET) { + // Baileys wa-bridge delivery: plain text, no Meta template needed. + messageBody = `${senderName} invited you to Flash! Join using this link: ${inviteLink}` + } else { + // Twilio path: approved WhatsApp template. + messageBody = JSON.stringify({ + templateName: "flash_invite", + templateVariables: { + "1": senderName, + "2": token, + }, + }) + } break case InviteMethod.SMS: diff --git a/test/flash/unit/services/notification/wa-bridge.spec.ts b/test/flash/unit/services/notification/wa-bridge.spec.ts new file mode 100644 index 000000000..b9bc581a8 --- /dev/null +++ b/test/flash/unit/services/notification/wa-bridge.spec.ts @@ -0,0 +1,81 @@ +import { notificationService, NotificationMethod } from "@services/notification" + +// The Twilio client is intentionally unconfigured in this suite (no TWILIO_* +// env), so the non-bridge path deterministically fails — which lets us assert +// the bridge path is what succeeded. + +describe("wa-bridge WhatsApp delivery", () => { + const realFetch = global.fetch + let fetchMock: jest.Mock + + beforeEach(() => { + fetchMock = jest.fn() + global.fetch = fetchMock as unknown as typeof fetch + process.env.WA_BRIDGE_URL = "http://bridge.local:3850/send" + process.env.WA_BRIDGE_SECRET = "sekrit" + }) + + afterEach(() => { + global.fetch = realFetch + delete process.env.WA_BRIDGE_URL + delete process.env.WA_BRIDGE_SECRET + }) + + const okResponse = (body: unknown, status = 200) => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response + + it("sends via the bridge with the auth header and {to, text} body", async () => { + fetchMock.mockResolvedValue(okResponse({ ok: true, id: "MSGID" })) + + const result = await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + "+18765550123", + "hello from flash", + ) + + expect(result).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("http://bridge.local:3850/send") + expect(init.method).toBe("POST") + expect(init.headers["X-Send-Token"]).toBe("sekrit") + expect(JSON.parse(init.body)).toEqual({ + to: "+18765550123", + text: "hello from flash", + }) + }) + + it("returns false when the bridge responds without ok:true", async () => { + fetchMock.mockResolvedValue(okResponse({ error: "unauthorized" }, 401)) + const result = await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + "+18765550123", + "hello", + ) + expect(result).toBe(false) + }) + + it("returns false when the bridge call throws (network)", async () => { + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")) + const result = await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + "+18765550123", + "hello", + ) + expect(result).toBe(false) + }) + + it("falls back to the Twilio path (and does not call the bridge) when unconfigured", async () => { + delete process.env.WA_BRIDGE_URL + delete process.env.WA_BRIDGE_SECRET + const result = await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + "+18765550123", + "hello", + ) + // No Twilio client in this suite -> the fallback path fails, proving the + // bridge really is the only reason the configured case succeeds. + expect(result).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/services/notifications/invite-body.spec.ts b/test/flash/unit/services/notifications/invite-body.spec.ts new file mode 100644 index 000000000..953541c2f --- /dev/null +++ b/test/flash/unit/services/notifications/invite-body.spec.ts @@ -0,0 +1,58 @@ +import { InviteMethod } from "@domain/invite" + +const mockSendNotification = jest.fn() +jest.mock("@services/notification", () => ({ + NotificationMethod: { EMAIL: "EMAIL", SMS: "SMS", WHATSAPP: "WHATSAPP" }, + notificationService: { + sendNotification: (...a: unknown[]) => mockSendNotification(...a), + }, +})) + +import { sendInviteNotification } from "@services/notifications/invite" + +const TOKEN = "a".repeat(40) + +describe("sendInviteNotification WHATSAPP body selection", () => { + beforeEach(() => { + mockSendNotification.mockReset() + mockSendNotification.mockResolvedValue(true) + }) + + afterEach(() => { + delete process.env.WA_BRIDGE_URL + delete process.env.WA_BRIDGE_SECRET + }) + + it("uses plain text with the invite link when the wa-bridge is configured", async () => { + process.env.WA_BRIDGE_URL = "http://bridge.local:3850/send" + process.env.WA_BRIDGE_SECRET = "sekrit" + + const ok = await sendInviteNotification({ + method: InviteMethod.WHATSAPP, + contact: "+18765550123", + token: TOKEN, + senderName: "dreadlocks", + }) + + expect(ok).toBe(true) + const body = mockSendNotification.mock.calls[0][2] as string + expect(body).toContain("dreadlocks invited you to Flash!") + expect(body).toContain(`token=${TOKEN}`) + expect(() => JSON.parse(body)).toThrow() // plain text, not a template blob + }) + + it("uses the Twilio template blob when the bridge is not configured", async () => { + const ok = await sendInviteNotification({ + method: InviteMethod.WHATSAPP, + contact: "+18765550123", + token: TOKEN, + senderName: "dreadlocks", + }) + + expect(ok).toBe(true) + const body = mockSendNotification.mock.calls[0][2] as string + const parsed = JSON.parse(body) + expect(parsed.templateName).toBe("flash_invite") + expect(parsed.templateVariables["2"]).toBe(TOKEN) + }) +})