feat(webhook): Twilio WhatsApp inbound route + Meta webhook hardening
Add POST /webhook/twilio/whatsapp for the pilot approval loop — Alex replies 1/2/3 to the Twilio number to approve post drafts. Includes HMAC-SHA1 signature validation, Redis dedup (wa_msg_seen:MessageSid), pilot_owner_phone allowlist, staleness check (7d), tracking link creation, and draft status update. Also fix two security bugs in the existing Meta webhook handler: fail-open when WHATSAPP_APP_SECRET unset (now 503), and missing length guard before timingSafeEqual (was RangeError → 500, now 403). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
34
src/index.ts
34
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;
|
||||
|
||||
294
src/webhooks/twilio-whatsapp.test.ts
Normal file
294
src/webhooks/twilio-whatsapp.test.ts
Normal file
@@ -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<string, string> = {
|
||||
Body: '1',
|
||||
From: `whatsapp:${PILOT_PHONE}`,
|
||||
MessageSid: MSG_SID,
|
||||
NumMedia: '0',
|
||||
To: 'whatsapp:+19547385805',
|
||||
};
|
||||
|
||||
function makeSignature(url: string, params: Record<string, string>): 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<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
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<typeof vi.fn>; send: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function mockPool(customerRows: object[], draftRows: object[]) {
|
||||
const mockQuery = vi.fn()
|
||||
.mockResolvedValueOnce([customerRows])
|
||||
.mockResolvedValueOnce([draftRows])
|
||||
.mockResolvedValue([[]]);
|
||||
(getPool as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(CUSTOMER_ID);
|
||||
(redis.set as ReturnType<typeof vi.fn>).mockResolvedValue('OK');
|
||||
(getPool as ReturnType<typeof vi.fn>).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('<Response>'));
|
||||
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<typeof vi.fn>).mockResolvedValue('OK');
|
||||
});
|
||||
|
||||
it('skips processing for a duplicate MessageSid', async () => {
|
||||
(redis.set as ReturnType<typeof vi.fn>).mockResolvedValue(null); // NX returns null when key exists
|
||||
(getPool as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue('tok_abc123456789012345678901');
|
||||
const mockQuery = vi.fn()
|
||||
.mockResolvedValueOnce([[{ pilot_owner_phone: PILOT_PHONE }]])
|
||||
.mockResolvedValueOnce([[draft]])
|
||||
.mockResolvedValue([[]]); // UPDATE
|
||||
(getPool as ReturnType<typeof vi.fn>).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'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
199
src/webhooks/twilio-whatsapp.ts
Normal file
199
src/webhooks/twilio-whatsapp.ts
Normal file
@@ -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 = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
||||
|
||||
export async function initTwilioWebhookMapping(): Promise<void> {
|
||||
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<string, string>,
|
||||
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<void> {
|
||||
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<CustomerRow[]>(
|
||||
'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<PostDraft[]>(
|
||||
`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<void> {
|
||||
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<string, string>;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user