const { getConfig, setConfig, getStatus, startSession, restartSession, logoutSession, getQr, requestPairingCode, createInstance, deleteInstance, } = require('../utils/whatsapp'); const { sendMail } = require('../utils/email'); // ─── Config management ──────────────────────────────────────────────────────── /** * GET /api/whatsapp/config * Returns the current WAWP credentials (token is masked). */ const getConfigHandler = async (req, res) => { try { const { token, instanceId } = await getConfig(); // Mask the token for display — show first 4 chars + asterisks const maskedToken = token ? token.slice(0, 4) + '*'.repeat(Math.max(0, token.length - 4)) : ''; res.json({ tokenMasked: maskedToken, instanceId: instanceId || '', hasToken: !!token, hasInstance: !!instanceId, configured: !!(token && instanceId), webhookUrl: await require('../utils/whatsapp').webhookUrl(), }); } catch (e) { res.status(500).json({ message: e?.message || 'Failed to load config' }); } }; /** * POST /api/whatsapp/config * Body: { token, instanceId } * Saves credentials to DB; instanceId is optional (keep existing if omitted). */ const saveConfigHandler = async (req, res) => { try { const { token, instanceId } = req.body || {}; const current = await getConfig(); // Resolve token: '_clear_' resets it; blank/absent keeps existing let resolvedToken = current.token; if (token === '_clear_') { resolvedToken = ''; } else if (token && token.trim()) { resolvedToken = token.trim(); } // Resolve instanceId: blank/absent keeps existing const resolvedInstanceId = (instanceId && instanceId.trim()) ? instanceId.trim() : current.instanceId || ''; await setConfig(resolvedToken, resolvedInstanceId); res.json({ message: 'WAWP configuration saved successfully.' }); } catch (e) { res.status(500).json({ message: e?.message || 'Failed to save config' }); } }; // ─── Instance lifecycle ─────────────────────────────────────────────────────── /** * POST /api/whatsapp/create-instance * Body: { name? } * Creates a new WAWP session instance and saves its id to the DB. */ const createInstanceHandler = async (req, res) => { try { const { name } = req.body || {}; const data = await createInstance(name); res.json({ message: 'Instance created and saved.', ...data }); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; /** * POST /api/whatsapp/delete-instance * Deletes the current WAWP instance and clears it from the DB. */ const deleteInstanceHandler = async (req, res) => { try { const data = await deleteInstance(); res.json({ message: 'Instance deleted.', ...data }); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; // ─── Admin session endpoints ────────────────────────────────────────────────── const getStatusHandler = async (req, res) => { try { res.json(await getStatus()); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; const getQrHandler = async (req, res) => { try { const data = await getQr(); // Normalise: strip leading "data:image/png;base64," if WAWP already includes it, // so the client always receives a clean base64 string it can prefix itself. if (data?.qr) { data.qr = data.qr.replace(/^data:image\/png;base64,/, ''); } res.json(data); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; const requestCodeHandler = async (req, res) => { try { const { phoneNumber } = req.body || {}; if (!phoneNumber) return res.status(400).json({ message: 'phoneNumber is required' }); res.json(await requestPairingCode(phoneNumber)); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; const logoutHandler = async (req, res) => { try { res.json(await logoutSession()); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; const startHandler = async (req, res) => { try { res.json(await startSession()); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; const restartHandler = async (req, res) => { try { res.json(await restartSession()); } catch (e) { res.status(500).json({ message: e?.response?.data?.message || e.message }); } }; // ─── Webhook — auto-recovery ────────────────────────────────────────────────── const MAX_ATTEMPTS = 3; const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between each attempt const STATUS_WAIT_MS = 10_000; // wait after restart before checking const handleWebhook = async (req, res) => { // Acknowledge immediately so WAWP doesn't time out res.status(200).json({ ok: true }); try { const { event, session } = req.body || {}; if (event === 'message') return await handleIncomingMessage(req); if (event !== 'session.status') return; const status = session?.status; // Only auto-recover on FAILED — STOPPED may be intentional if (status !== 'FAILED') return; console.warn('[whatsapp webhook] Session FAILED — starting recovery...'); let recovered = false; for (let i = 0; i < MAX_ATTEMPTS; i++) { await new Promise(r => setTimeout(r, RETRY_DELAYS[i])); try { await restartSession(); await new Promise(r => setTimeout(r, STATUS_WAIT_MS)); const info = await getStatus(); const s = info?.status; if (s === 'WORKING' || s === 'SCAN_QR_CODE' || s === 'STARTING') { recovered = true; console.info(`[whatsapp webhook] Session recovered on attempt ${i + 1} (status: ${s})`); break; } console.warn(`[whatsapp webhook] Attempt ${i + 1}: status still ${s}`); } catch (e) { console.warn(`[whatsapp webhook] Attempt ${i + 1} error:`, e.message); } } if (!recovered) { console.error(`[whatsapp webhook] Could not recover after ${MAX_ATTEMPTS} attempts — sending admin alert`); const { getSettingSync } = require('../utils/settingsCache'); const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '') || getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''); const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'); const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`; const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }); await sendMail({ to: adminEmail, subject: 'WhatsApp session is down — action required', text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`, html: `
The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.
Time: ${when}
Please visit the admin dashboard to re-scan the QR code and reconnect.
`, }).catch(() => {}); } } catch (e) { console.error('[whatsapp webhook] Unhandled error in recovery handler:', e.message); } }; /** * An incoming WhatsApp message (WAWP `message` event). The only thing we act on is an * opt-out reply — "STOP" — which stops News & announcements on WhatsApp for that number * (event updates still go out). Only trusted when the URL carries our webhook secret, * since WAWP doesn't sign webhooks. */ async function handleIncomingMessage(req) { const crypto = require('crypto'); const { getWebhookSecret, phoneFromChatId, sendText } = require('../utils/whatsapp'); const { isStopKeyword, whatsAppStop } = require('../utils/privacy'); const given = String(req.query?.secret || ''); const secret = await getWebhookSecret(); if (given.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret))) { console.warn('[whatsapp webhook] Ignored a message event without a valid secret'); return; } // Proof for the POPIA readiness page that WAWP's webhook (and so STOP) is set up. const prisma = require('../config/db'); const now = new Date().toISOString(); prisma.appSetting.upsert({ where: { key: 'wawp_webhook_last_seen' }, update: { value: now }, create: { key: 'wawp_webhook_last_seen', value: now } }).catch(() => {}); const payload = req.body?.payload || {}; if (payload.fromMe || !isStopKeyword(payload.body)) return; const phone = phoneFromChatId(payload.from); if (!phone) { // Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number). console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from); return; } const result = await whatsAppStop(phone); if (!result) return; await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {}); } module.exports = { getConfigHandler, saveConfigHandler, createInstanceHandler, deleteInstanceHandler, getStatusHandler, getQrHandler, requestCodeHandler, logoutHandler, startHandler, restartHandler, handleWebhook, };