diff --git a/src/index.ts b/src/index.ts index 7f5c27e..b798ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { import { tools, handleToolCall, stripAccountParam } from './tools.js'; import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial, getOpenApiSpecChatGPT } from './manifest.js'; import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js'; +import { handleTwilioWhatsApp, initTwilioWebhookMapping } from './webhooks/twilio-whatsapp.js'; import { storeCredential, type Platform } from './multitenancy/credential-store.js'; import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js'; import { @@ -1347,18 +1348,23 @@ app.get('/webhook/whatsapp', (req, res) => { const WHATSAPP_APP_SECRET = process.env.WHATSAPP_APP_SECRET ?? ''; app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => { - // Verify Meta HMAC signature when app secret is configured - if (WHATSAPP_APP_SECRET) { - const sig = req.headers['x-hub-signature-256'] as string | undefined; - if (!sig) { - res.status(403).send('Missing signature'); - return; - } - const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex'); - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - res.status(403).send('Invalid signature'); - return; - } + // Fail-closed: refuse to process unsigned requests if app secret is not configured + if (!WHATSAPP_APP_SECRET) { + console.error('[webhook/whatsapp] WHATSAPP_APP_SECRET not set — refusing to process'); + res.status(503).send('Webhook validation not configured'); + return; + } + const sig = req.headers['x-hub-signature-256'] as string | undefined; + if (!sig) { + res.status(403).send('Missing signature'); + return; + } + const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex'); + const sigBuf = Buffer.from(sig); + const expectedBuf = Buffer.from(expected); + if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) { + res.status(403).send('Invalid signature'); + return; } // Always acknowledge immediately to prevent Meta retries (20s window) @@ -1383,6 +1389,9 @@ app.post('/webhook/twilio/voice', (_req, res) => { ); }); +// ── Twilio WhatsApp inbound — pilot approval loop ── +app.post('/webhook/twilio/whatsapp', handleTwilioWhatsApp); + // ── Auth endpoints ────────────────────────────────────────────── app.post('/api/auth/signup', express.json(), async (req, res) => { @@ -2301,6 +2310,7 @@ app.get('/health', (_req, res) => { async function main() { await initDatabase(); + await initTwilioWebhookMapping(); // Ensure the pre-registered SquareMCP OAuth app exists for the browser DCR flow const oauthClientId = process.env.OAUTH_CLIENT_ID; diff --git a/src/webhooks/twilio-whatsapp.test.ts b/src/webhooks/twilio-whatsapp.test.ts new file mode 100644 index 0000000..a4c9090 --- /dev/null +++ b/src/webhooks/twilio-whatsapp.test.ts @@ -0,0 +1,294 @@ +import crypto from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { + validateTwilioSignature, + handleTwilioWhatsApp, + handleApproval, +} from './twilio-whatsapp.js'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../redis.js', () => ({ + default: { + set: vi.fn(), + get: vi.fn(), + }, +})); + +vi.mock('../db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../tracking-links.js', () => ({ + createTrackingLink: vi.fn(), +})); + +vi.mock('../clients/whatsapp.js', () => ({ + sendMessage: vi.fn().mockResolvedValue({ success: true, message_id: 'SM_reply' }), +})); + +import redis from '../redis.js'; +import { getPool } from '../db.js'; +import { createTrackingLink } from '../tracking-links.js'; +import { sendMessage } from '../clients/whatsapp.js'; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +const AUTH_TOKEN = 'test_auth_token_abc123'; +const WEBHOOK_URL = 'https://hermes.squaremcp.com/webhook/twilio/whatsapp'; +const CUSTOMER_ID = 'cust_lodge_brothers'; +const PILOT_PHONE = '+15551234567'; +const MSG_SID = 'SM_abc123'; + +const DEFAULT_PARAMS: Record = { + Body: '1', + From: `whatsapp:${PILOT_PHONE}`, + MessageSid: MSG_SID, + NumMedia: '0', + To: 'whatsapp:+19547385805', +}; + +function makeSignature(url: string, params: Record): string { + const sortedParams = Object.keys(params).sort().map(k => k + params[k]).join(''); + return crypto.createHmac('sha1', AUTH_TOKEN).update(url + sortedParams).digest('base64'); +} + +function makeReq(overrides: { + body?: Record; + headers?: Record; + hostname?: string; +} = {}): Request { + const body = overrides.body ?? { ...DEFAULT_PARAMS }; + const sig = makeSignature(WEBHOOK_URL, body); + const defaultHeaders = { 'x-twilio-signature': sig }; + const headers = overrides.headers !== undefined ? overrides.headers : defaultHeaders; + return { + headers, + body, + hostname: 'hermes.squaremcp.com', + } as unknown as Request; +} + +function makeRes() { + const res = { + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + }; + return res as unknown as Response & { status: ReturnType; send: ReturnType; type: ReturnType }; +} + +function mockPool(customerRows: object[], draftRows: object[]) { + const mockQuery = vi.fn() + .mockResolvedValueOnce([customerRows]) + .mockResolvedValueOnce([draftRows]) + .mockResolvedValue([[]]); + (getPool as ReturnType).mockReturnValue({ query: mockQuery }); + return mockQuery; +} + +function makeDraft(overrides: Partial<{ + id: number; + draft_text_wa: string; + destination_url: string | null; + created_at: Date; +}> = {}) { + return { + id: 1, + draft_text_wa: 'Check out our mortgage rates this week!', + destination_url: 'https://calendly.com/alex/30min', + created_at: new Date(), + ...overrides, + }; +} + +// ── validateTwilioSignature ─────────────────────────────────────────────────── + +describe('validateTwilioSignature', () => { + it('returns true for a valid signature', () => { + const params = { ...DEFAULT_PARAMS }; + const sig = makeSignature(WEBHOOK_URL, params); + expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, sig)).toBe(true); + }); + + it('returns false for an invalid signature', () => { + const params = { ...DEFAULT_PARAMS }; + const sig = makeSignature(WEBHOOK_URL, params); + expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, sig + 'x')).toBe(false); + }); + + it('returns false for a wrong auth token', () => { + const params = { ...DEFAULT_PARAMS }; + const sig = makeSignature(WEBHOOK_URL, params); + expect(validateTwilioSignature('wrong_token', WEBHOOK_URL, params, sig)).toBe(false); + }); + + it('returns false (not throws) when signature is very short — length guard', () => { + const params = { ...DEFAULT_PARAMS }; + expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, params, 'short')).toBe(false); + }); + + it('returns false for empty signature string', () => { + expect(validateTwilioSignature(AUTH_TOKEN, WEBHOOK_URL, DEFAULT_PARAMS, '')).toBe(false); + }); +}); + +// ── handleTwilioWhatsApp (route handler) ──────────────────────────────────── + +describe('handleTwilioWhatsApp', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + process.env.TWILIO_AUTH_TOKEN = AUTH_TOKEN; + process.env.PUBLIC_WEBHOOK_BASE_URL = 'https://hermes.squaremcp.com'; + (redis.get as ReturnType).mockResolvedValue(CUSTOMER_ID); + (redis.set as ReturnType).mockResolvedValue('OK'); + (getPool as ReturnType).mockReturnValue({ query: vi.fn().mockResolvedValue([[]]) }); + }); + + it('returns 503 when TWILIO_AUTH_TOKEN is not set', async () => { + delete process.env.TWILIO_AUTH_TOKEN; + const req = makeReq(); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + }); + + it('returns 403 when X-Twilio-Signature header is missing', async () => { + const req = makeReq({ headers: {} }); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Missing signature'); + }); + + it('returns 403 for an invalid signature', async () => { + const req = makeReq({ headers: { 'x-twilio-signature': 'bad_signature_value' } }); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Invalid signature'); + }); + + it('returns 403 (not 500) for a malformed short signature', async () => { + const req = makeReq({ headers: { 'x-twilio-signature': 'x' } }); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Invalid signature'); + }); + + it('returns 200 with TwiML for a valid request', async () => { + const req = makeReq(); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith(expect.stringContaining('')); + expect(res.type).toHaveBeenCalledWith('text/xml'); + }); + + it('uses PUBLIC_WEBHOOK_BASE_URL for signature URL construction', async () => { + // The default req uses WEBHOOK_URL derived from PUBLIC_WEBHOOK_BASE_URL + // If PUBLIC_WEBHOOK_BASE_URL is correct, the valid signature validates + const req = makeReq(); + const res = makeRes(); + await handleTwilioWhatsApp(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +// ── handleApproval ────────────────────────────────────────────────────────── + +describe('handleApproval', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PUBLIC_WEBHOOK_BASE_URL = 'https://hermes.squaremcp.com'; + (redis.set as ReturnType).mockResolvedValue('OK'); + }); + + it('skips processing for a duplicate MessageSid', async () => { + (redis.set as ReturnType).mockResolvedValue(null); // NX returns null when key exists + (getPool as ReturnType).mockReturnValue({ query: vi.fn() }); + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0); + expect(getPool().query).not.toHaveBeenCalled(); + }); + + it('ignores messages from senders not matching pilot_owner_phone', async () => { + mockPool([{ pilot_owner_phone: '+19999999999' }], []); + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('replies asking to text when NumMedia > 0', async () => { + mockPool([{ pilot_owner_phone: PILOT_PHONE }], []); + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '', 1); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + to: PILOT_PHONE, + message: expect.stringContaining('1, 2, or 3'), + })); + }); + + it('approves the correct draft, creates tracking link, and sends confirmation', async () => { + const draft = makeDraft({ id: 42, destination_url: 'https://calendly.com/alex' }); + (createTrackingLink as ReturnType).mockResolvedValue('tok_abc123456789012345678901'); + const mockQuery = vi.fn() + .mockResolvedValueOnce([[{ pilot_owner_phone: PILOT_PHONE }]]) + .mockResolvedValueOnce([[draft]]) + .mockResolvedValue([[]]); // UPDATE + (getPool as ReturnType).mockReturnValue({ query: mockQuery }); + + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0); + + expect(createTrackingLink).toHaveBeenCalledWith(expect.objectContaining({ + customerId: CUSTOMER_ID, + draftId: 42, + destinationUrl: 'https://calendly.com/alex', + expiresAt: null, + })); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + to: PILOT_PHONE, + message: expect.stringContaining('Track clicks'), + })); + // Verify draft was marked approved + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'approved'"), + [42] + ); + }); + + it('replies with expiry message for drafts older than 7 days', async () => { + const oldDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + const draft = makeDraft({ created_at: oldDate }); + mockPool([{ pilot_owner_phone: PILOT_PHONE }], [draft]); + + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0); + + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('expired'), + })); + expect(createTrackingLink).not.toHaveBeenCalled(); + }); + + it('replies with help listing pending drafts for unrecognized text', async () => { + const draft = makeDraft({ draft_text_wa: 'Big sale on mortgage rates this week!' }); + mockPool([{ pilot_owner_phone: PILOT_PHONE }], [draft]); + + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, 'hello', 0); + + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('1, 2, or 3'), + })); + expect(createTrackingLink).not.toHaveBeenCalled(); + }); + + it('sends a no-drafts message when there are no pending drafts', async () => { + mockPool([{ pilot_owner_phone: PILOT_PHONE }], []); + + await handleApproval(CUSTOMER_ID, PILOT_PHONE, MSG_SID, '1', 0); + + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('No pending drafts'), + })); + }); +}); diff --git a/src/webhooks/twilio-whatsapp.ts b/src/webhooks/twilio-whatsapp.ts new file mode 100644 index 0000000..b4f86c1 --- /dev/null +++ b/src/webhooks/twilio-whatsapp.ts @@ -0,0 +1,199 @@ +import crypto from 'crypto'; +import type { Request, Response } from 'express'; +import redis from '../redis.js'; +import { getPool } from '../db.js'; +import { createTrackingLink } from '../tracking-links.js'; +import { sendMessage } from '../clients/whatsapp.js'; +import type { RowDataPacket } from 'mysql2/promise'; + +const TWIML_ACK = ''; + +export async function initTwilioWebhookMapping(): Promise { + const twilioNumber = (process.env.TWILIO_WHATSAPP_NUMBER ?? '').replace(/^whatsapp:/, ''); + const pilotCustomerId = process.env.PILOT_CUSTOMER_ID ?? ''; + if (!twilioNumber || !pilotCustomerId) { + console.warn('[twilio-webhook] TWILIO_WHATSAPP_NUMBER or PILOT_CUSTOMER_ID unset — customer mapping not seeded'); + return; + } + try { + await redis.set(`twilio_wa_number:${twilioNumber}`, pilotCustomerId); + console.log(`[twilio-webhook] mapped ${twilioNumber} → ${pilotCustomerId}`); + } catch (err) { + console.error('[twilio-webhook] Redis unavailable at startup — customer mapping not seeded; inbound messages will be dropped until next restart:', err); + } +} + +export function validateTwilioSignature( + authToken: string, + url: string, + params: Record, + sig: string +): boolean { + const sortedParams = Object.keys(params).sort().map(k => k + params[k]).join(''); + const expected = crypto.createHmac('sha1', authToken).update(url + sortedParams).digest('base64'); + const expectedBuf = Buffer.from(expected); + const sigBuf = Buffer.from(sig); + if (expectedBuf.length !== sigBuf.length) return false; + return crypto.timingSafeEqual(expectedBuf, sigBuf); +} + +interface PostDraft extends RowDataPacket { + id: number; + draft_text_wa: string; + destination_url: string | null; + created_at: Date; +} + +interface CustomerRow extends RowDataPacket { + pilot_owner_phone: string | null; +} + +export async function handleApproval( + customerId: string, + from: string, + messageId: string, + body: string, + numMedia: number +): Promise { + const dedupKey = `wa_msg_seen:${messageId}`; + const isNew = await redis.set(dedupKey, '1', { NX: true, EX: 86400 }); + if (!isNew) { + console.log(`[twilio-webhook] duplicate MessageSid=${messageId}, skipping`); + return; + } + + const pool = getPool(); + const [customers] = await pool.query( + 'SELECT pilot_owner_phone FROM customers WHERE id = ?', + [customerId] + ); + const pilotPhone = customers[0]?.pilot_owner_phone ?? ''; + if (!pilotPhone || from !== pilotPhone) { + console.log(`[twilio-webhook] sender ${from} not in allowlist, customerId=${customerId}, MessageSid=${messageId}`); + return; + } + + if (numMedia > 0) { + await sendMessage({ to: from, message: 'Please text 1, 2, or 3 to approve a draft. Voice notes and images are not supported here.' }); + return; + } + + const [rows] = await pool.query( + `SELECT id, draft_text_wa, destination_url, created_at + FROM post_drafts + WHERE tenant_id = ? AND status = 'pending' + ORDER BY created_at DESC`, + [customerId] + ); + const drafts = rows as PostDraft[]; + + if (drafts.length === 0) { + await sendMessage({ to: from, message: 'No pending drafts right now. New ones arrive on Monday.' }); + return; + } + + const newestDate = new Date(drafts[0].created_at); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + if (newestDate < sevenDaysAgo) { + await sendMessage({ to: from, message: 'Those drafts expired — new ones arrive Monday.' }); + return; + } + + const newestDay = newestDate.toDateString(); + const batch = drafts.filter(d => new Date(d.created_at).toDateString() === newestDay).slice(0, 3); + + const trimmed = body.trim(); + if (!['1', '2', '3'].includes(trimmed)) { + const list = batch.map((d, i) => `${i + 1}. ${[...d.draft_text_wa].slice(0, 80).join('')}`).join('\n'); + await sendMessage({ to: from, message: `Reply 1, 2, or 3 to approve a draft:\n\n${list}` }); + return; + } + + const index = parseInt(trimmed, 10) - 1; + const draft = batch[index]; + if (!draft) { + await sendMessage({ to: from, message: `Only ${batch.length} draft${batch.length === 1 ? '' : 's'} available. Reply 1–${batch.length}.` }); + return; + } + + let trackedUrl: string | null = null; + if (draft.destination_url) { + try { + const token = await createTrackingLink({ + customerId, + draftId: draft.id, + destinationUrl: draft.destination_url, + expiresAt: null, + }); + const baseUrl = (process.env.PUBLIC_WEBHOOK_BASE_URL ?? '').replace(/\/$/, ''); + trackedUrl = `${baseUrl}/t/${token}`; + } catch (err) { + console.error(`[twilio-webhook] createTrackingLink failed, draft=${draft.id}, customerId=${customerId}:`, err); + } + } + + await pool.query( + "UPDATE post_drafts SET status = 'approved', approved_at = NOW() WHERE id = ?", + [draft.id] + ); + + const confirmMsg = trackedUrl + ? `Draft ${index + 1} approved and live! Track clicks: ${trackedUrl}` + : `Draft ${index + 1} approved and live!`; + await sendMessage({ to: from, message: confirmMsg }); + + console.log(`[twilio-webhook] approved draft=${draft.id}, customerId=${customerId}, MessageSid=${messageId}`); +} + +export async function handleTwilioWhatsApp(req: Request, res: Response): Promise { + const authToken = process.env.TWILIO_AUTH_TOKEN ?? ''; + if (!authToken) { + console.error('[twilio-webhook] TWILIO_AUTH_TOKEN not set — refusing to process'); + res.status(503).send('Webhook validation not configured'); + return; + } + + const sig = req.headers['x-twilio-signature'] as string | undefined; + if (!sig) { + res.status(403).send('Missing signature'); + return; + } + + const baseUrl = (process.env.PUBLIC_WEBHOOK_BASE_URL ?? '').replace(/\/$/, ''); + const webhookUrl = baseUrl + ? `${baseUrl}/webhook/twilio/whatsapp` + : (() => { + console.warn('[twilio-webhook] PUBLIC_WEBHOOK_BASE_URL not set — using req.hostname fallback; signature validation WILL fail behind K8s ingress'); + return `https://${req.hostname}/webhook/twilio/whatsapp`; + })(); + + const params = req.body as Record; + + if (!validateTwilioSignature(authToken, webhookUrl, params, sig)) { + res.status(403).send('Invalid signature'); + return; + } + + res.type('text/xml').status(200).send(TWIML_ACK); + + const from = (params.From ?? '').replace(/^whatsapp:/, ''); + const to = (params.To ?? '').replace(/^whatsapp:/, ''); + const messageId = params.MessageSid ?? ''; + const body = params.Body ?? ''; + const numMedia = parseInt(params.NumMedia ?? '0', 10); + + if (!from || !messageId) { + console.error('[twilio-webhook] missing From or MessageSid in payload'); + return; + } + + const customerId = await redis.get(`twilio_wa_number:${to}`).catch(() => null); + if (!customerId) { + console.warn(`[twilio-webhook] no customer mapping for Twilio number ${to}`); + return; + } + + handleApproval(customerId, from, messageId, body, numMedia).catch(err => { + console.error(`[twilio-webhook] unhandled error, MessageSid=${messageId}, customerId=${customerId}:`, err); + }); +}