const axios = require('axios'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const BASE = 'https://api.wawp.net/v2'; // ─── Config cache (DB-backed, env fallback) ─────────────────────────────────── let _configCache = null; let _configCacheAt = 0; const CONFIG_TTL_MS = 30_000; // 30 s /** * Load WAWP credentials from DB (AppSetting table), falling back to .env. * Result is cached for 30 seconds so repeated calls don't hit the DB. */ async function getConfig() { const now = Date.now(); if (_configCache && (now - _configCacheAt) < CONFIG_TTL_MS) return _configCache; try { const prisma = require('../config/db'); const rows = await prisma.appSetting.findMany({ where: { key: { in: ['WAWP_ACCESS_TOKEN', 'WAWP_INSTANCE_ID'] } }, }); const map = Object.fromEntries(rows.map(r => [r.key, r.value])); _configCache = { token: map.WAWP_ACCESS_TOKEN || process.env.WAWP_ACCESS_TOKEN || '', instanceId: map.WAWP_INSTANCE_ID || process.env.WAWP_INSTANCE_ID || '', }; } catch { // DB unavailable — fall back to env vars _configCache = { token: process.env.WAWP_ACCESS_TOKEN || '', instanceId: process.env.WAWP_INSTANCE_ID || '', }; } _configCacheAt = now; return _configCache; } /** Save WAWP credentials to DB and invalidate the cache. */ async function setConfig(token, instanceId) { const prisma = require('../config/db'); await prisma.$transaction([ prisma.appSetting.upsert({ where: { key: 'WAWP_ACCESS_TOKEN' }, update: { value: token }, create: { key: 'WAWP_ACCESS_TOKEN', value: token }, }), prisma.appSetting.upsert({ where: { key: 'WAWP_INSTANCE_ID' }, update: { value: instanceId }, create: { key: 'WAWP_INSTANCE_ID', value: instanceId }, }), ]); _configCache = null; // force reload on next getConfig() } async function isConfigured() { const { token, instanceId } = await getConfig(); return !!(token && instanceId); } // ─── SA phone normalisation ─────────────────────────────────────────────────── /** * Normalises any common South African phone format to 27xxxxxxxxx (11 digits). * Handles: 0821234567 / 082 123 4567 / +27821234567 / +27 82 123 4567 * Returns null when the number cannot be resolved to a valid SA mobile. */ function normalizeZAPhone(raw) { if (!raw) return null; let digits = String(raw).replace(/\D/g, ''); // Local format: leading 0 + 9 digits (total 10) if (digits.startsWith('0') && digits.length === 10) { digits = '27' + digits.slice(1); } // Must now be exactly 11 digits starting with 27 if (/^27\d{9}$/.test(digits)) return digits; return null; } /** Returns true when raw is a valid SA mobile number. */ function isValidZAPhone(raw) { return normalizeZAPhone(raw) !== null; } /** Converts a phone number to the WAWP chatId format (e.g. 27821234567@c.us). */ function toChatId(raw) { const n = normalizeZAPhone(raw); return n ? `${n}@c.us` : null; } // ─── "Session not found" auto-recovery ─────────────────────────────────────── /** * Returns true when the WAWP error message indicates the session doesn't exist. */ function isSessionNotFound(e) { const msg = (e?.response?.data?.message || e?.message || '').toLowerCase(); return msg.includes('session not found') || msg.includes('instance not found'); } /** Extracts the WAWP API's own error reason when present, instead of axios's generic * "Request failed with status code NNN" (which carries no information about what went wrong). */ function wawpErrorMessage(e) { return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error'; } /** * If the WAWP API reports "Session not found", clear the stale instance ID * from the DB so the admin UI drops back to the Session Instance setup step. * * @param {Error} e - The error thrown by a WAWP API call */ async function handleSessionNotFound(e) { if (!isSessionNotFound(e)) throw e; // not our problem — re-throw console.warn('[whatsapp] Session not found — clearing instance ID from DB...'); const { token, instanceId } = await getConfig(); // Try to delete the stale session on WAWP (may fail — that's okay) try { await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId }); } catch (delErr) { console.warn('[whatsapp] Delete stale session failed (ignored):', delErr?.response?.data?.message || delErr.message); } // Clear the instance ID from DB so the frontend goes back to Step 2 await setConfig(token, ''); console.info('[whatsapp] Instance ID cleared — admin must set up a new session instance.'); throw new Error('SESSION_NOT_FOUND'); } // ─── Session management ─────────────────────────────────────────────────────── async function getStatus() { const { token, instanceId } = await getConfig(); try { const res = await axios.post(`${BASE}/session/info`, { access_token: token, instance_id: instanceId }); return res.data; } catch (e) { await handleSessionNotFound(e); } } async function startSession() { const { token, instanceId } = await getConfig(); try { const res = await axios.post(`${BASE}/session/start`, { access_token: token, instance_id: instanceId }); return res.data; } catch (e) { await handleSessionNotFound(e); } } async function restartSession() { const { token, instanceId } = await getConfig(); try { const res = await axios.post(`${BASE}/session/restart`, { access_token: token, instance_id: instanceId }); return res.data; } catch (e) { await handleSessionNotFound(e); } } async function logoutSession() { const { token, instanceId } = await getConfig(); const res = await axios.post(`${BASE}/session/logout`, { access_token: token, instance_id: instanceId }); return res.data; } async function createInstance(name) { const { token } = await getConfig(); const label = name || `hope-events-${Date.now()}`; const res = await axios.post(`${BASE}/session/create`, { access_token: token, name: label }); const newInstanceId = res.data?.instance_id || res.data?.id; if (!newInstanceId) throw new Error('Create instance returned no instance_id'); await setConfig(token, newInstanceId); return { ...res.data, instance_id: newInstanceId }; } async function deleteInstance() { const { token, instanceId } = await getConfig(); if (!instanceId) throw new Error('No instance configured'); const res = await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId }); // Clear instance_id from DB after deletion await setConfig(token, ''); return res.data; } async function getQr() { const { token, instanceId } = await getConfig(); try { const res = await axios.post(`${BASE}/auth/qr-image`, { access_token: token, instance_id: instanceId }); return res.data; // { qr: 'data:image/png;base64,...' } } catch (e) { await handleSessionNotFound(e); } } async function requestPairingCode(phoneNumber) { const { token, instanceId } = await getConfig(); const normalized = normalizeZAPhone(phoneNumber); if (!normalized) throw new Error('Invalid South African phone number'); try { const res = await axios.post(`${BASE}/auth/request-code`, { access_token: token, instance_id: instanceId, phone_number: normalized, }); return res.data; // { code: 'ABCD-1234' } } catch (e) { await handleSessionNotFound(e); } } // ─── Messaging ──────────────────────────────────────────────────────────────── async function sendText(toPhone, message) { if (!(await isConfigured())) return; const chatId = toChatId(toPhone); if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; } const { token, instanceId } = await getConfig(); try { await axios.post(`${BASE}/send/text`, { access_token: token, instance_id: instanceId, chatId, message, }); } catch (e) { if (isSessionNotFound(e)) { try { await handleSessionNotFound(e); } catch {} throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.'); } throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`); } } /** * Copies a local PDF to a temporary public URL, sends it via WAWP, * then schedules the temp file for deletion after 5 minutes. */ async function sendPdf(toPhone, localPdfPath, filename, caption) { if (!(await isConfigured())) return; const chatId = toChatId(toPhone); if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping PDF:', toPhone); return; } const tempDir = path.join(__dirname, '../../public/uploads/tickets-temp'); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); const tempName = `${uuidv4()}.pdf`; const tempPath = path.join(tempDir, tempName); fs.copyFileSync(localPdfPath, tempPath); const backendUrl = (process.env.BACKEND_URL || '').replace(/\/$/, ''); const isLocalhost = !backendUrl || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(backendUrl); if (isLocalhost) { try { fs.unlinkSync(tempPath); } catch {} throw new Error( `WhatsApp PDF delivery requires a publicly accessible backend URL. ` + `BACKEND_URL is currently "${backendUrl || '(not set)'}". ` + `Set BACKEND_URL to your public backend URL (e.g. https://api.yourdomain.com) in your .env file.` ); } const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`; const { token, instanceId } = await getConfig(); try { await axios.post(`${BASE}/send/pdf`, { access_token: token, instance_id: instanceId, chatId, file: { url: pdfUrl, filename: filename || 'tickets.pdf', mimetype: 'application/pdf', }, caption: caption || '', }); } catch (e) { try { fs.unlinkSync(tempPath); } catch {} if (isSessionNotFound(e)) { try { await handleSessionNotFound(e); } catch {} throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.'); } throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`); } // Clean up after 5 minutes — WAWP will have fetched the file by then setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000); } module.exports = { getConfig, setConfig, normalizeZAPhone, isValidZAPhone, toChatId, isConfigured, getStatus, startSession, restartSession, logoutSession, createInstance, deleteInstance, getQr, requestPairingCode, sendText, sendPdf, };