← 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,
};