← back to Costa Rica
lib/whatsapp.js
169 lines
'use strict';
// WhatsApp — Meta Cloud API direct client + webhook helpers.
// Full message surface: text, template, interactive (buttons/list), media
// (image/document), location, reaction, and typing/read receipts. Persists
// every in/out message to whatsapp_messages and tracks the 24h session window.
//
// SANDBOX-SAFE: with no WHATSAPP_TOKEN set, liveMode=false and sends are
// simulated (logged + persisted, no Meta call) so booking notifications are
// exercisable before the WABA number is provisioned.
//
// Env (all gated in the go-live memo):
// WHATSAPP_TOKEN permanent system-user token
// WHATSAPP_PHONE_ID phone number id (sender)
// WHATSAPP_VERIFY_TOKEN webhook GET verification token
// WHATSAPP_APP_SECRET for X-Hub-Signature-256 verification
// WHATSAPP_API_VERSION default v21.0
const crypto = require('crypto');
const { pool } = require('./db');
const { fetchT } = require('./payments/http'); // bound live Graph API calls (no infinite hang)
const TOKEN = process.env.WHATSAPP_TOKEN || '';
const PHONE_ID = process.env.WHATSAPP_PHONE_ID || '';
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN || 'cr-verify-sandbox';
const APP_SECRET = process.env.WHATSAPP_APP_SECRET || '';
const VER = process.env.WHATSAPP_API_VERSION || 'v21.0';
const LIVE = !!(TOKEN && PHONE_ID);
const GRAPH = (path) => `https://graph.facebook.com/${VER}/${path}`;
async function _send(payload) {
if (!LIVE) {
return { messages: [{ id: `wamid.SBX_${crypto.randomBytes(8).toString('hex')}` }], sandbox: true };
}
// fetchT bounds connect+headers AND the body read (shared with tilopay/onvo/plaid).
// WHY IT MATTERS HERE: sends are best-effort and callers (confirmBooking, the
// webhook auto-reply) wrap them in try/catch — but that catches an ERROR, not a
// HANG. A stalled Graph API connection on raw fetch() never rejects, so the
// caller's `await wa.sendText(...)` would hang FOREVER, stalling confirmBooking and
// its money-path callers (/pay, the payment webhook, GET /payments/:id). fetchT
// turns the hang into a PROVIDER_TIMEOUT throw the existing try/catch handles.
const res = await fetchT(GRAPH(`${PHONE_ID}/messages`), {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
});
const j = await res.json();
if (!res.ok) throw new Error(`wa send HTTP ${res.status}: ${JSON.stringify(j).slice(0, 300)}`);
return j;
}
// --- persistence -----------------------------------------------------------
async function contactByWaId(waId, profileName) {
const { rows } = await pool.query(
`INSERT INTO whatsapp_contacts (wa_id, profile_name) VALUES ($1,$2)
ON CONFLICT (wa_id) DO UPDATE SET profile_name=COALESCE(EXCLUDED.profile_name, whatsapp_contacts.profile_name)
RETURNING *`, [waId, profileName || null]);
return rows[0];
}
async function logMessage({ waMessageId, contactId, bookingId, direction, msgType, body, payload, status }) {
await pool.query(
`INSERT INTO whatsapp_messages (wa_message_id, contact_id, booking_id, direction, msg_type, body, payload, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (wa_message_id) DO NOTHING`,
[waMessageId || null, contactId || null, bookingId || null, direction, msgType, body || null,
payload ? JSON.stringify(payload) : null, status || null]);
}
// --- outbound message types ------------------------------------------------
async function sendText(to, text, { bookingId, previewUrl = false } = {}) {
const r = await _send({ to, type: 'text', text: { body: text, preview_url: previewUrl } });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'text', body: text, payload: r });
return r;
}
// Template = the ONLY thing allowed outside the 24h session window (must be pre-approved in Meta).
async function sendTemplate(to, name, langCode = 'es', components = [], { bookingId } = {}) {
const r = await _send({ to, type: 'template', template: { name, language: { code: langCode }, components } });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'template', body: name, payload: r });
return r;
}
async function sendButtons(to, bodyText, buttons, { bookingId, header, footer } = {}) {
const action = { buttons: buttons.slice(0, 3).map((b, i) => ({ type: 'reply', reply: { id: b.id || `btn_${i}`, title: b.title.slice(0, 20) } })) };
const interactive = { type: 'button', body: { text: bodyText }, action };
if (header) interactive.header = { type: 'text', text: header };
if (footer) interactive.footer = { text: footer };
const r = await _send({ to, type: 'interactive', interactive });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
return r;
}
async function sendList(to, bodyText, buttonLabel, sections, { bookingId, header } = {}) {
const interactive = { type: 'list', body: { text: bodyText }, action: { button: buttonLabel, sections } };
if (header) interactive.header = { type: 'text', text: header };
const r = await _send({ to, type: 'interactive', interactive });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
return r;
}
async function sendImage(to, link, caption, { bookingId } = {}) {
const r = await _send({ to, type: 'image', image: { link, caption } });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'image', body: caption, payload: r });
return r;
}
async function sendDocument(to, link, filename, caption, { bookingId } = {}) {
const r = await _send({ to, type: 'document', document: { link, filename, caption } });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'document', body: filename, payload: r });
return r;
}
async function sendLocation(to, lat, lng, name, address, { bookingId } = {}) {
const r = await _send({ to, type: 'location', location: { latitude: lat, longitude: lng, name, address } });
const c = await contactByWaId(to);
await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'location', body: name, payload: r });
return r;
}
async function markRead(waMessageId) {
if (!LIVE) return { sandbox: true };
return _send({ status: 'read', message_id: waMessageId });
}
// --- webhook ---------------------------------------------------------------
// GET verification handshake
function verifyChallenge(query) {
if (query['hub.mode'] === 'subscribe' && query['hub.verify_token'] === VERIFY_TOKEN) {
return { ok: true, challenge: query['hub.challenge'] };
}
return { ok: false };
}
// POST signature check (X-Hub-Signature-256: sha256=...)
function verifySignature(headers, rawBody) {
if (!APP_SECRET) return !LIVE; // sandbox accepts
const sig = (headers['x-hub-signature-256'] || '').replace('sha256=', '');
const expect = crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
try { return !!(sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))); } catch { return false; }
}
// Parse an inbound webhook body into normalized events + persist inbound msgs.
async function handleInbound(body) {
const out = [];
for (const entry of body.entry || []) {
for (const ch of entry.changes || []) {
const v = ch.value || {};
const profileName = v.contacts?.[0]?.profile?.name;
for (const m of v.messages || []) {
const c = await contactByWaId(m.from, profileName);
await pool.query(`UPDATE whatsapp_contacts SET last_inbound_at=NOW() WHERE id=$1`, [c.id]);
const body_ = m.text?.body || m.interactive?.button_reply?.title || m.interactive?.list_reply?.title || m.button?.text || null;
await logMessage({ waMessageId: m.id, contactId: c.id, direction: 'in', msgType: m.type, body: body_, payload: m, status: 'received' });
out.push({ contact: c, message: m, text: body_ });
}
for (const s of v.statuses || []) {
await pool.query(`UPDATE whatsapp_messages SET status=$1 WHERE wa_message_id=$2`, [s.status, s.id]);
}
}
}
return out;
}
module.exports = {
get liveMode() { return LIVE; }, get webhookSecretSet() { return !!APP_SECRET; },
// true only when a REAL (non-default) verify token is configured — the hardcoded
// 'cr-verify-sandbox' fallback must not be used for a live WABA (public repo default).
get verifyTokenSet() { return !!(process.env.WHATSAPP_VERIFY_TOKEN && VERIFY_TOKEN !== 'cr-verify-sandbox'); },
VERIFY_TOKEN,
sendText, sendTemplate, sendButtons, sendList, sendImage, sendDocument, sendLocation, markRead,
verifyChallenge, verifySignature, handleInbound, contactByWaId,
};