feat(pos): replace Meta Cloud API WhatsApp with Evolution API (self-hosted)
Switch from Meta Business Cloud API to Evolution API for WhatsApp integration. Evolution API is self-hosted, free, and connects via WhatsApp Web QR code scan. - Add docker-compose for Evolution API deployment - Rewrite whatsapp_service.py for Evolution API endpoints - Add instance management (create, QR, status, logout) to blueprint - Add QR code scanning UI with connection status indicator - Add duplicate message prevention in webhook handler - Update config.py with EVOLUTION_API_URL/KEY (remove old Meta vars) - Add setup documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* whatsapp.js — WhatsApp Business conversation UI
|
||||
* whatsapp.js — WhatsApp via Evolution API
|
||||
*
|
||||
* Connection flow: Create instance -> Scan QR -> Connected
|
||||
* Left panel: conversation list (phone numbers + last message preview)
|
||||
* Right panel: chat view with message bubbles
|
||||
* Bottom: text input + send button
|
||||
* Toolbar: "Enviar Cotizacion" button
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -15,8 +15,10 @@
|
||||
var API = '/pos/api/whatsapp';
|
||||
var activePhone = null;
|
||||
var pollTimer = null;
|
||||
var statusPollTimer = null;
|
||||
var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
// -- Helpers ---------------------------------------------------------------
|
||||
|
||||
function authHeaders() {
|
||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
@@ -51,7 +53,6 @@
|
||||
|
||||
function fmtPhone(phone) {
|
||||
if (!phone) return '';
|
||||
// Format Mexican numbers nicely: 52 1 55 1234 5678
|
||||
if (phone.length === 13 && phone.startsWith('521')) {
|
||||
return '+52 1 ' + phone.slice(3, 5) + ' ' + phone.slice(5, 9) + ' ' + phone.slice(9);
|
||||
}
|
||||
@@ -61,20 +62,154 @@
|
||||
return '+' + phone;
|
||||
}
|
||||
|
||||
// ── DOM refs ────────────────────────────────────────────────────────
|
||||
// -- DOM refs --------------------------------------------------------------
|
||||
|
||||
var convList = document.getElementById('convList');
|
||||
var chatMessages = document.getElementById('chatMessages');
|
||||
var chatHeader = document.getElementById('chatHeaderPhone');
|
||||
var chatInput = document.getElementById('chatInput');
|
||||
var sendBtn = document.getElementById('sendBtn');
|
||||
var newChatBtn = document.getElementById('newChatBtn');
|
||||
var emptyState = document.getElementById('emptyState');
|
||||
var chatPanel = document.getElementById('chatPanel');
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var statusText = document.getElementById('statusText');
|
||||
var convList = document.getElementById('convList');
|
||||
var chatMessages = document.getElementById('chatMessages');
|
||||
var chatHeader = document.getElementById('chatHeaderPhone');
|
||||
var chatInput = document.getElementById('chatInput');
|
||||
var sendBtn = document.getElementById('sendBtn');
|
||||
var newChatBtn = document.getElementById('newChatBtn');
|
||||
var emptyState = document.getElementById('emptyState');
|
||||
var chatPanel = document.getElementById('chatPanel');
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var statusText = document.getElementById('statusText');
|
||||
var connectSection = document.getElementById('connectSection');
|
||||
var messengerArea = document.getElementById('messengerArea');
|
||||
var qrImg = document.getElementById('qrImg');
|
||||
var qrPlaceholder = document.getElementById('qrPlaceholder');
|
||||
var connectBtn = document.getElementById('connectBtn');
|
||||
var disconnectBtn = document.getElementById('disconnectBtn');
|
||||
var refreshQrBtn = document.getElementById('refreshQrBtn');
|
||||
|
||||
// ── Load conversations ──────────────────────────────────────────────
|
||||
// -- Connection management -------------------------------------------------
|
||||
|
||||
function checkInstanceStatus() {
|
||||
api('GET', '/instance/status').then(function (data) {
|
||||
var state = (data.instance || data).state || data.state || 'close';
|
||||
updateConnectionUI(state);
|
||||
}).catch(function () {
|
||||
updateConnectionUI('close');
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectionUI(state) {
|
||||
connectionState = state;
|
||||
|
||||
if (state === 'open') {
|
||||
statusDot.className = 'status-dot status-dot--ok';
|
||||
statusText.textContent = 'Conectado';
|
||||
connectSection.style.display = 'none';
|
||||
messengerArea.style.display = 'flex';
|
||||
disconnectBtn.style.display = '';
|
||||
connectBtn.style.display = 'none';
|
||||
} else if (state === 'connecting') {
|
||||
statusDot.className = 'status-dot status-dot--warn';
|
||||
statusText.textContent = 'Escaneando QR...';
|
||||
connectSection.style.display = 'flex';
|
||||
messengerArea.style.display = 'none';
|
||||
disconnectBtn.style.display = 'none';
|
||||
connectBtn.style.display = 'none';
|
||||
refreshQrBtn.style.display = '';
|
||||
} else {
|
||||
// close or unknown
|
||||
statusDot.className = 'status-dot status-dot--error';
|
||||
statusText.textContent = 'Desconectado';
|
||||
connectSection.style.display = 'flex';
|
||||
messengerArea.style.display = 'none';
|
||||
disconnectBtn.style.display = 'none';
|
||||
connectBtn.style.display = '';
|
||||
refreshQrBtn.style.display = 'none';
|
||||
qrImg.style.display = 'none';
|
||||
qrPlaceholder.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function doConnect() {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Creando instancia...';
|
||||
|
||||
api('POST', '/instance/create').then(function (data) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Conectar WhatsApp';
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + (data.error.message || data.error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Instance created, now fetch QR
|
||||
fetchQR();
|
||||
}).catch(function () {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Conectar WhatsApp';
|
||||
alert('Error de red al crear instancia');
|
||||
});
|
||||
}
|
||||
|
||||
function fetchQR() {
|
||||
qrPlaceholder.textContent = 'Generando QR...';
|
||||
|
||||
api('GET', '/instance/qr').then(function (data) {
|
||||
var base64 = data.base64 || data.qrcode || '';
|
||||
if (base64) {
|
||||
qrImg.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
|
||||
qrImg.style.display = 'block';
|
||||
qrPlaceholder.style.display = 'none';
|
||||
refreshQrBtn.style.display = '';
|
||||
updateConnectionUI('connecting');
|
||||
|
||||
// Start polling for connection state while QR is shown
|
||||
startStatusPolling();
|
||||
} else if (data.instance && data.instance.state === 'open') {
|
||||
// Already connected
|
||||
updateConnectionUI('open');
|
||||
loadConversations();
|
||||
} else {
|
||||
qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.';
|
||||
qrPlaceholder.style.display = '';
|
||||
qrImg.style.display = 'none';
|
||||
}
|
||||
}).catch(function () {
|
||||
qrPlaceholder.textContent = 'Error al obtener QR';
|
||||
});
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (!confirm('Desconectar WhatsApp?')) return;
|
||||
api('POST', '/instance/logout').then(function () {
|
||||
updateConnectionUI('close');
|
||||
stopStatusPolling();
|
||||
});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling();
|
||||
statusPollTimer = setInterval(function () {
|
||||
api('GET', '/instance/status').then(function (data) {
|
||||
var state = (data.instance || data).state || data.state || 'close';
|
||||
if (state === 'open') {
|
||||
updateConnectionUI('open');
|
||||
stopStatusPolling();
|
||||
loadConversations();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener('click', doConnect);
|
||||
disconnectBtn.addEventListener('click', doDisconnect);
|
||||
refreshQrBtn.addEventListener('click', fetchQR);
|
||||
|
||||
// -- Load conversations ----------------------------------------------------
|
||||
|
||||
function loadConversations() {
|
||||
api('GET', '/conversations').then(function (data) {
|
||||
@@ -95,7 +230,6 @@
|
||||
});
|
||||
convList.innerHTML = html;
|
||||
|
||||
// Click handlers
|
||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
openConversation(el.getAttribute('data-phone'));
|
||||
@@ -106,7 +240,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open a conversation ─────────────────────────────────────────────
|
||||
// -- Open a conversation ---------------------------------------------------
|
||||
|
||||
function openConversation(phone) {
|
||||
activePhone = phone;
|
||||
@@ -114,7 +248,6 @@
|
||||
emptyState.style.display = 'none';
|
||||
chatPanel.style.display = 'flex';
|
||||
|
||||
// Highlight in list
|
||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
|
||||
});
|
||||
@@ -147,7 +280,7 @@
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Send message ────────────────────────────────────────────────────
|
||||
// -- Send message ----------------------------------------------------------
|
||||
|
||||
function doSend() {
|
||||
var text = chatInput.value.trim();
|
||||
@@ -178,7 +311,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── New conversation ────────────────────────────────────────────────
|
||||
// -- New conversation ------------------------------------------------------
|
||||
|
||||
newChatBtn.addEventListener('click', function () {
|
||||
var phone = prompt('Numero de telefono (formato: 5215512345678):');
|
||||
@@ -189,7 +322,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Send quotation modal ────────────────────────────────────────────
|
||||
// -- Send quotation --------------------------------------------------------
|
||||
|
||||
var quoteBtn = document.getElementById('sendQuoteBtn');
|
||||
if (quoteBtn) {
|
||||
@@ -208,43 +341,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Polling for new messages ────────────────────────────────────────
|
||||
// -- Polling for new messages ----------------------------------------------
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(function () {
|
||||
if (activePhone) loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}, 10000); // every 10s
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// ── Connection status indicator ─────────────────────────────────────
|
||||
// -- Init ------------------------------------------------------------------
|
||||
|
||||
function checkStatus() {
|
||||
// Check if we can reach the API (proxy for "connected")
|
||||
fetch(API + '/conversations', { headers: authHeaders() })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
statusDot.className = 'status-dot status-dot--ok';
|
||||
statusText.textContent = 'Conectado';
|
||||
} else {
|
||||
statusDot.className = 'status-dot status-dot--warn';
|
||||
statusText.textContent = 'Sin credenciales';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
statusDot.className = 'status-dot status-dot--error';
|
||||
statusText.textContent = 'Desconectado';
|
||||
});
|
||||
}
|
||||
checkInstanceStatus();
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────
|
||||
// Also check periodically (every 30s) in case connection drops
|
||||
setInterval(checkInstanceStatus, 30000);
|
||||
|
||||
loadConversations();
|
||||
checkStatus();
|
||||
setInterval(checkStatus, 30000);
|
||||
|
||||
// ── User info for sidebar ───────────────────────────────────────────
|
||||
// -- User info for sidebar -------------------------------------------------
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
window.POS_USER = {
|
||||
|
||||
Reference in New Issue
Block a user