From 5f92fe83ba329ff7f9f7fe4fafae0636ae286d4b Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 5 Apr 2026 03:15:52 +0000 Subject: [PATCH] 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) --- docker/docker-compose.evolution.yml | 23 ++ docs/WHATSAPP-SETUP.md | 38 +++ pos/blueprints/whatsapp_bp.py | 201 ++++++++++---- pos/config.py | 7 +- pos/services/whatsapp_service.py | 404 +++++++++++++++------------- pos/static/js/whatsapp.js | 210 +++++++++++---- pos/templates/whatsapp.html | 82 +++++- 7 files changed, 672 insertions(+), 293 deletions(-) create mode 100644 docker/docker-compose.evolution.yml create mode 100644 docs/WHATSAPP-SETUP.md diff --git a/docker/docker-compose.evolution.yml b/docker/docker-compose.evolution.yml new file mode 100644 index 0000000..9e41462 --- /dev/null +++ b/docker/docker-compose.evolution.yml @@ -0,0 +1,23 @@ +version: '3' +services: + evolution-api: + image: atendai/evolution-api:latest + container_name: evolution-api + restart: always + ports: + - "8080:8080" + environment: + - SERVER_URL=http://localhost:8080 + - AUTHENTICATION_API_KEY=nexus-evolution-key-2026 + - DATABASE_ENABLED=true + - DATABASE_PROVIDER=postgresql + - DATABASE_CONNECTION_URI=postgresql://nexus:nexus_autoparts_2026@host.docker.internal:5432/evolution_api + - DATABASE_CONNECTION_CLIENT_NAME=evolution + - QRCODE_LIMIT=10 + - WEBHOOK_GLOBAL_URL=http://host.docker.internal:5001/pos/api/whatsapp/webhook + - WEBHOOK_GLOBAL_ENABLED=true + - WEBHOOK_EVENTS_MESSAGES_UPSERT=true + volumes: + - evolution_data:/evolution/instances +volumes: + evolution_data: diff --git a/docs/WHATSAPP-SETUP.md b/docs/WHATSAPP-SETUP.md new file mode 100644 index 0000000..56ac4c8 --- /dev/null +++ b/docs/WHATSAPP-SETUP.md @@ -0,0 +1,38 @@ +# Configuracion de WhatsApp — Evolution API + +## Requisitos +- Docker instalado en el servidor +- Puerto 8080 disponible + +## Instalacion +1. `cd /home/Autopartes/docker` +2. `docker-compose -f docker-compose.evolution.yml up -d` +3. Esperar ~30 segundos a que inicie + +## Crear base de datos para Evolution +```bash +PGPASSWORD=nexus_autoparts_2026 psql -U nexus -h localhost -c "CREATE DATABASE evolution_api OWNER nexus;" +``` + +## Conectar WhatsApp +1. Ir a /pos/whatsapp en el POS +2. Click "Conectar WhatsApp" +3. Escanear el QR con tu telefono (WhatsApp > Dispositivos vinculados > Vincular) +4. Listo — los mensajes empiezan a llegar + +## Notas +- Cada refaccionaria puede conectar su propio numero +- La sesion se mantiene mientras el Docker este corriendo +- Si el telefono pierde internet por >14 dias, hay que re-escanear +- El nombre de la instancia se deriva del nombre de base de datos del tenant + +## Variables de entorno (opcionales) +```bash +EVOLUTION_API_URL=http://localhost:8080 # URL de Evolution API +EVOLUTION_API_KEY=nexus-evolution-key-2026 # API key configurada en docker-compose +``` + +## Troubleshooting +- Si el QR no aparece, verificar que el contenedor este corriendo: `docker ps` +- Si el webhook no recibe mensajes, verificar que WEBHOOK_GLOBAL_URL apunte al servidor Flask +- Logs: `docker logs evolution-api` diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index 21bc934..c70e8d4 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -1,20 +1,22 @@ # /home/Autopartes/pos/blueprints/whatsapp_bp.py -"""WhatsApp Business API blueprint. +"""WhatsApp via Evolution API blueprint. Endpoints: - GET /pos/api/whatsapp/webhook — Meta webhook verification (public) - POST /pos/api/whatsapp/webhook — Receive incoming messages (public) - POST /pos/api/whatsapp/send — Send message to phone (auth) - POST /pos/api/whatsapp/send-quote/ — Send quotation via WhatsApp (auth) - GET /pos/api/whatsapp/conversations — List recent conversations (auth) - GET /pos/api/whatsapp/conversations/ — Conversation history (auth) + POST /pos/api/whatsapp/instance/create -- Create WhatsApp instance (auth) + GET /pos/api/whatsapp/instance/qr -- Get QR code for scanning (auth) + GET /pos/api/whatsapp/instance/status -- Check connection status (auth) + POST /pos/api/whatsapp/instance/logout -- Disconnect instance (auth) + POST /pos/api/whatsapp/webhook -- Receive messages from Evolution (public) + POST /pos/api/whatsapp/send -- Send message (auth) + POST /pos/api/whatsapp/send-quote/ -- Send quotation via WhatsApp (auth) + GET /pos/api/whatsapp/conversations -- List recent conversations (auth) + GET /pos/api/whatsapp/conversations/ -- Conversation history (auth) """ import logging from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn -from config import WHATSAPP_VERIFY_TOKEN from services import whatsapp_service logger = logging.getLogger(__name__) @@ -22,78 +24,171 @@ logger = logging.getLogger(__name__) whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp") -# ── Webhook (PUBLIC — no auth, Meta must be able to call these) ────────── +def _get_instance_name(): + """Derive Evolution instance name from tenant info.""" + return getattr(g, 'tenant_db_name', None) or f'tenant_{g.tenant_id}' -@whatsapp_bp.route("/webhook", methods=["GET"]) -def webhook_verify(): - """Meta webhook verification challenge. - Meta sends: hub.mode=subscribe&hub.verify_token=&hub.challenge= - We must return the challenge value if the verify_token matches. - """ - mode = request.args.get("hub.mode", "") - token = request.args.get("hub.verify_token", "") - challenge = request.args.get("hub.challenge", "") +# -- Instance management (authenticated) ------------------------------------- - if mode == "subscribe" and token == WHATSAPP_VERIFY_TOKEN: - logger.info("WhatsApp webhook verified") - return challenge, 200 - else: - logger.warning("WhatsApp webhook verification failed — token mismatch") - return "Forbidden", 403 +@whatsapp_bp.route("/instance/create", methods=["POST"]) +@require_auth() +def instance_create(): + """Create a new WhatsApp instance for this tenant.""" + instance_name = _get_instance_name() + result = whatsapp_service.create_instance(instance_name) + return jsonify(result) +@whatsapp_bp.route("/instance/qr", methods=["GET"]) +@require_auth() +def instance_qr(): + """Get QR code image (base64) for WhatsApp connection.""" + instance_name = _get_instance_name() + result = whatsapp_service.get_qr_code(instance_name) + return jsonify(result) + + +@whatsapp_bp.route("/instance/status", methods=["GET"]) +@require_auth() +def instance_status(): + """Check WhatsApp connection status for this tenant.""" + instance_name = _get_instance_name() + result = whatsapp_service.get_instance_status(instance_name) + return jsonify(result) + + +@whatsapp_bp.route("/instance/logout", methods=["POST"]) +@require_auth() +def instance_logout(): + """Disconnect WhatsApp instance.""" + instance_name = _get_instance_name() + result = whatsapp_service.logout_instance(instance_name) + return jsonify(result) + + +# -- Webhook (PUBLIC -- no auth, Evolution API must be able to call this) ----- + @whatsapp_bp.route("/webhook", methods=["POST"]) def webhook_receive(): - """Receive incoming WhatsApp messages from Meta. + """Receive incoming WhatsApp messages from Evolution API. - Stores in DB (using tenant_id=1 as default for webhook context) - and triggers auto-response via AI chatbot if available. + Evolution sends webhooks for all message events. + We store incoming messages in DB and trigger auto-response if available. """ payload = request.get_json(silent=True) if not payload: return "OK", 200 + # Evolution sends the event type in the payload + event = payload.get('event', '') + + # We only care about incoming messages + if event not in ('messages.upsert', ''): + return "OK", 200 + result = whatsapp_service.process_incoming(payload) + # Determine tenant from instance name in webhook + instance_name = payload.get('instance', '') + tenant_id = _resolve_tenant_from_instance(instance_name) + # Store incoming message in DB if it was a real message - if result.get("type") == "message" and result.get("phone"): + if result.get('type') == 'message' and result.get('phone'): _store_message( - phone=result["phone"], - direction="incoming", - message_text=result.get("text", ""), - message_type=result.get("message_type", "text"), - wa_message_id=result.get("wa_message_id", ""), - status="received", + phone=result['phone'], + direction='incoming', + message_text=result.get('text', ''), + message_type=result.get('message_type', 'text'), + wa_message_id=result.get('message_id', ''), + status='received', + tenant_id=tenant_id, ) - # Store auto-reply if one was generated - if result.get("auto_reply"): + # If AI generated an auto-reply, send it and store it + if result.get('auto_reply'): + send_result = whatsapp_service.send_message( + instance_name, result['phone'], result['auto_reply'] + ) _store_message( - phone=result["phone"], - direction="outgoing", - message_text=result["auto_reply"], - message_type="text", - wa_message_id=result.get("auto_reply_wa_id", ""), - status="sent", + phone=result['phone'], + direction='outgoing', + message_text=result['auto_reply'], + message_type='text', + wa_message_id=send_result.get('key', {}).get('id', ''), + status='sent', + tenant_id=tenant_id, ) - # Always return 200 to Meta (otherwise they retry) + elif result.get('type') == 'outgoing' and result.get('phone'): + # Our own outgoing messages echoed back by Evolution + _store_message( + phone=result['phone'], + direction='outgoing', + message_text=result.get('text', ''), + message_type=result.get('message_type', 'text'), + wa_message_id=result.get('message_id', ''), + status='sent', + tenant_id=tenant_id, + ) + return "OK", 200 +def _resolve_tenant_from_instance(instance_name): + """Resolve tenant_id from Evolution instance name. + + Instance names follow the pattern: tenant_ or the db_name itself. + """ + if not instance_name: + return 1 # default fallback + + if instance_name.startswith('tenant_'): + try: + return int(instance_name.split('_', 1)[1]) + except (ValueError, IndexError): + pass + + # Try to look up by db_name in master + try: + from tenant_db import get_master_conn + conn = get_master_conn() + cur = conn.cursor() + cur.execute("SELECT id FROM tenants WHERE db_name = %s", (instance_name,)) + row = cur.fetchone() + cur.close() + conn.close() + if row: + return row[0] + except Exception: + logger.debug("Could not resolve tenant from instance: %s", instance_name) + + return 1 # default fallback + + def _store_message(phone, direction, message_text, message_type="text", wa_message_id="", status="sent", related_type=None, related_id=None, tenant_id=None): """Store a WhatsApp message in the tenant DB.""" tid = tenant_id or getattr(g, "tenant_id", None) if not tid: - # Webhook context — try tenant 1 as default tid = 1 try: conn = get_tenant_conn(tid) cur = conn.cursor() + + # Avoid duplicate messages (Evolution may send the same message ID twice) + if wa_message_id: + cur.execute( + "SELECT id FROM whatsapp_messages WHERE wa_message_id = %s", + (wa_message_id,) + ) + if cur.fetchone(): + cur.close() + conn.close() + return None + cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text, message_type, wa_message_id, @@ -112,7 +207,7 @@ def _store_message(phone, direction, message_text, message_type="text", return None -# ── Authenticated endpoints ────────────────────────────────────────────── +# -- Authenticated endpoints -------------------------------------------------- @whatsapp_bp.route("/send", methods=["POST"]) @require_auth() @@ -125,14 +220,15 @@ def send_message(): if not phone or not text: return jsonify({"error": "phone and message required"}), 400 - result = whatsapp_service.send_message(phone, text) + instance_name = _get_instance_name() + result = whatsapp_service.send_message(instance_name, phone, text) if "error" not in result: _store_message( phone=phone, direction="outgoing", message_text=text, - wa_message_id=result.get("wa_message_id", ""), + wa_message_id=result.get('key', {}).get('id', ''), status="sent", tenant_id=g.tenant_id, ) @@ -152,11 +248,11 @@ def send_quote(quote_id): if not phone: return jsonify({"error": "phone required"}), 400 + instance_name = _get_instance_name() conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: - # Load quotation header cur.execute(""" SELECT id, folio, customer_id, subtotal, tax, total, status, notes FROM sales @@ -166,7 +262,6 @@ def send_quote(quote_id): if not quote: return jsonify({"error": "Quotation not found"}), 404 - # Load quotation items cur.execute(""" SELECT si.quantity, si.unit_price, si.discount_pct, si.subtotal, i.name, i.part_number @@ -182,7 +277,6 @@ def send_quote(quote_id): "name": f"{r[4]} ({r[5]})" if r[5] else r[4], }) - # Load tenant business name from tenant_db import get_master_conn master = get_master_conn() mcur = master.cursor() @@ -203,15 +297,14 @@ def send_quote(quote_id): "notes": quote[7], } - result = whatsapp_service.send_quote(phone, quote_data) + result = whatsapp_service.send_quote(instance_name, phone, quote_data) if "error" not in result: - # Build the same formatted text the service would have sent _store_message( phone=phone, direction="outgoing", message_text=f"[Cotizacion #{quote_data['quote_number']}] Total: ${quote_data['total']:,.2f}", - wa_message_id=result.get("wa_message_id", ""), + wa_message_id=result.get('key', {}).get('id', ''), status="sent", related_type="quotation", related_id=quote_id, @@ -252,7 +345,6 @@ def list_conversations(): "last_at": r[5].isoformat() if r[5] else None, }) - # Sort by most recent first conversations.sort(key=lambda c: c["last_at"] or "", reverse=True) return jsonify({"conversations": conversations}) @@ -297,7 +389,6 @@ def conversation_history(phone): "created_at": r[9].isoformat() if r[9] else None, }) - # Also get total count cur.execute("SELECT COUNT(*) FROM whatsapp_messages WHERE phone = %s", (phone,)) total = cur.fetchone()[0] diff --git a/pos/config.py b/pos/config.py index a94f89f..6fc69aa 100644 --- a/pos/config.py +++ b/pos/config.py @@ -32,10 +32,9 @@ SMTP_USER = os.environ.get('SMTP_USER', '') SMTP_PASS = os.environ.get('SMTP_PASS', '') SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com') -# WhatsApp Business Cloud API -WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "") -WHATSAPP_PHONE_ID = os.environ.get("WHATSAPP_PHONE_ID", "") -WHATSAPP_VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN", "nexus-wa-verify-2026") +# Evolution API (self-hosted WhatsApp via WhatsApp Web protocol) +EVOLUTION_API_URL = os.environ.get('EVOLUTION_API_URL', 'http://localhost:8080') +EVOLUTION_API_KEY = os.environ.get('EVOLUTION_API_KEY', 'nexus-evolution-key-2026') # Multi-currency DEFAULT_CURRENCY = os.environ.get('DEFAULT_CURRENCY', 'MXN') diff --git a/pos/services/whatsapp_service.py b/pos/services/whatsapp_service.py index 7a15e19..97903f8 100644 --- a/pos/services/whatsapp_service.py +++ b/pos/services/whatsapp_service.py @@ -1,137 +1,190 @@ -"""WhatsApp Business API service via Meta Cloud API. +"""WhatsApp service via Evolution API (self-hosted, free). -Handles sending and receiving WhatsApp messages. -Uses Meta's Cloud API (free for 1,000 service conversations/month). - -Setup: - 1. Create a Meta Business account at business.facebook.com - 2. Create a WhatsApp Business app at developers.facebook.com - 3. Get a permanent access token and Phone Number ID - 4. Set environment variables: - WHATSAPP_TOKEN= - WHATSAPP_PHONE_ID= - WHATSAPP_VERIFY_TOKEN= - 5. Configure the webhook URL in Meta Developer Console: - https:///pos/api/whatsapp/webhook +Evolution API connects to WhatsApp Web via QR code scan. +Docs: https://doc.evolution-api.com/ """ import logging import requests -from config import WHATSAPP_TOKEN, WHATSAPP_PHONE_ID +from config import EVOLUTION_API_URL, EVOLUTION_API_KEY logger = logging.getLogger(__name__) -API_VERSION = "v18.0" -BASE_URL = f"https://graph.facebook.com/{API_VERSION}" - - -def _headers(): - return { - "Authorization": f"Bearer {WHATSAPP_TOKEN}", - "Content-Type": "application/json", - } +HEADERS = { + 'apikey': EVOLUTION_API_KEY, + 'Content-Type': 'application/json' +} def is_configured(): - """Return True if WhatsApp credentials are set.""" - return bool(WHATSAPP_TOKEN and WHATSAPP_PHONE_ID) + """Return True if Evolution API credentials are set.""" + return bool(EVOLUTION_API_URL and EVOLUTION_API_KEY) -# ── Sending ────────────────────────────────────────────────────────────── +# -- Instance management ----------------------------------------------------- -def send_message(to_phone, message_text): - """Send a plain-text message via WhatsApp Cloud API. +def create_instance(instance_name): + """Create a WhatsApp instance (one per tenant/phone number).""" + try: + resp = requests.post( + f'{EVOLUTION_API_URL}/instance/create', + headers=HEADERS, + json={ + 'instanceName': instance_name, + 'qrcode': True, + 'integration': 'WHATSAPP-BAILEYS' + }, + timeout=15 + ) + return resp.json() + except Exception as e: + logger.exception("Failed to create Evolution instance") + return {'error': str(e)} + + +def get_qr_code(instance_name): + """Get QR code to connect WhatsApp. + + Returns dict with 'base64' key containing data:image/png;base64,... string. + """ + try: + resp = requests.get( + f'{EVOLUTION_API_URL}/instance/connect/{instance_name}', + headers=HEADERS, + timeout=15 + ) + return resp.json() + except Exception as e: + logger.exception("Failed to get QR code") + return {'error': str(e)} + + +def get_instance_status(instance_name): + """Check if instance is connected. + + Returns dict with 'state' key: 'open' | 'close' | 'connecting'. + """ + try: + resp = requests.get( + f'{EVOLUTION_API_URL}/instance/connectionState/{instance_name}', + headers=HEADERS, + timeout=10 + ) + return resp.json() + except Exception as e: + logger.exception("Failed to get instance status") + return {'error': str(e), 'state': 'close'} + + +def logout_instance(instance_name): + """Disconnect WhatsApp instance.""" + try: + resp = requests.delete( + f'{EVOLUTION_API_URL}/instance/logout/{instance_name}', + headers=HEADERS, + timeout=10 + ) + return resp.json() + except Exception as e: + logger.exception("Failed to logout instance") + return {'error': str(e)} + + +def delete_instance(instance_name): + """Delete instance completely.""" + try: + resp = requests.delete( + f'{EVOLUTION_API_URL}/instance/delete/{instance_name}', + headers=HEADERS, + timeout=10 + ) + return resp.json() + except Exception as e: + logger.exception("Failed to delete instance") + return {'error': str(e)} + + +# -- Sending messages --------------------------------------------------------- + +def send_message(instance_name, to_phone, message_text): + """Send text message. Args: - to_phone: recipient phone in E.164 format (e.g. '5215512345678') + instance_name: Evolution instance name (tenant identifier) + to_phone: phone in format 5214421234567 (country code + number, no +) message_text: text content Returns: - dict with 'wa_message_id' on success or 'error' on failure. + dict with response or 'error' key on failure. """ if not is_configured(): - return {"error": "WhatsApp not configured — set WHATSAPP_TOKEN and WHATSAPP_PHONE_ID"} - - url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages" - payload = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": to_phone, - "type": "text", - "text": {"preview_url": False, "body": message_text}, - } + return {'error': 'Evolution API not configured'} try: - resp = requests.post(url, json=payload, headers=_headers(), timeout=15) + resp = requests.post( + f'{EVOLUTION_API_URL}/message/sendText/{instance_name}', + headers=HEADERS, + json={'number': to_phone, 'text': message_text}, + timeout=15 + ) data = resp.json() if resp.status_code in (200, 201): - wa_id = data.get("messages", [{}])[0].get("id", "") - return {"wa_message_id": wa_id} + return data else: - err = data.get("error", {}).get("message", resp.text) - logger.error("WhatsApp send failed: %s", err) - return {"error": err} + err = data.get('message', data.get('error', resp.text)) + logger.error("Evolution send failed: %s", err) + return {'error': err} except Exception as e: - logger.exception("WhatsApp send exception") - return {"error": str(e)} + logger.exception("Evolution send exception") + return {'error': str(e)} -def send_template(to_phone, template_name, parameters=None): - """Send a pre-approved template message (for initiating conversations). - - Args: - to_phone: E.164 phone number - template_name: approved template name (e.g. 'order_confirmation') - parameters: list of {"type": "text", "text": "value"} dicts - """ - if not is_configured(): - return {"error": "WhatsApp not configured"} - - url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages" - components = [] - if parameters: - components.append({ - "type": "body", - "parameters": parameters, - }) - - payload = { - "messaging_product": "whatsapp", - "to": to_phone, - "type": "template", - "template": { - "name": template_name, - "language": {"code": "es_MX"}, - "components": components, - }, - } - +def send_image(instance_name, to_phone, image_url, caption=''): + """Send image message.""" try: - resp = requests.post(url, json=payload, headers=_headers(), timeout=15) - data = resp.json() - if resp.status_code in (200, 201): - wa_id = data.get("messages", [{}])[0].get("id", "") - return {"wa_message_id": wa_id} - else: - err = data.get("error", {}).get("message", resp.text) - logger.error("WhatsApp template send failed: %s", err) - return {"error": err} + resp = requests.post( + f'{EVOLUTION_API_URL}/message/sendMedia/{instance_name}', + headers=HEADERS, + json={ + 'number': to_phone, + 'mediatype': 'image', + 'media': image_url, + 'caption': caption + }, + timeout=30 + ) + return resp.json() except Exception as e: - logger.exception("WhatsApp template send exception") - return {"error": str(e)} + logger.exception("Evolution image send exception") + return {'error': str(e)} -def send_quote(to_phone, quote_data): - """Send a formatted quotation to a customer. +def send_document(instance_name, to_phone, doc_url, filename, caption=''): + """Send document (PDF, etc).""" + try: + resp = requests.post( + f'{EVOLUTION_API_URL}/message/sendMedia/{instance_name}', + headers=HEADERS, + json={ + 'number': to_phone, + 'mediatype': 'document', + 'media': doc_url, + 'fileName': filename, + 'caption': caption + }, + timeout=30 + ) + return resp.json() + except Exception as e: + logger.exception("Evolution document send exception") + return {'error': str(e)} - Args: - to_phone: E.164 phone number - quote_data: dict with keys: business_name, quote_number, items[], subtotal, - tax, total, validity_days, notes (optional) - """ + +def send_quote(instance_name, to_phone, quote_data): + """Send a formatted quotation to a customer.""" biz = quote_data.get("business_name", "Nexus Autoparts") - qnum = quote_data.get("quote_number", "—") + qnum = quote_data.get("quote_number", "--") items = quote_data.get("items", []) subtotal = quote_data.get("subtotal", 0) tax = quote_data.get("tax", 0) @@ -141,15 +194,15 @@ def send_quote(to_phone, quote_data): lines = [ f"*{biz}*", f"Cotizacion #{qnum}", - "─────────────────", + "---", ] for it in items: name = it.get("name", "Articulo") qty = it.get("quantity", 1) price = it.get("unit_price", 0) - lines.append(f" {qty}x {name} — ${price:,.2f}") + lines.append(f" {qty}x {name} -- ${price:,.2f}") - lines.append("─────────────────") + lines.append("---") lines.append(f"Subtotal: ${subtotal:,.2f}") lines.append(f"IVA: ${tax:,.2f}") lines.append(f"*Total: ${total:,.2f}*") @@ -159,46 +212,36 @@ def send_quote(to_phone, quote_data): if notes: lines.append(f"Nota: {notes}") - return send_message(to_phone, "\n".join(lines)) + return send_message(instance_name, to_phone, "\n".join(lines)) -def send_order_confirmation(to_phone, sale_data): - """Send order / sale confirmation. - - Args: - to_phone: E.164 phone number - sale_data: dict with keys: business_name, folio, items[], total, payment_method - """ +def send_order_confirmation(instance_name, to_phone, sale_data): + """Send order / sale confirmation.""" biz = sale_data.get("business_name", "Nexus Autoparts") - folio = sale_data.get("folio", "—") + folio = sale_data.get("folio", "--") total = sale_data.get("total", 0) method = sale_data.get("payment_method", "efectivo") lines = [ f"*{biz}*", f"Confirmacion de venta #{folio}", - "─────────────────", + "---", ] for it in sale_data.get("items", []): name = it.get("name", "Articulo") qty = it.get("quantity", 1) lines.append(f" {qty}x {name}") - lines.append("─────────────────") + lines.append("---") lines.append(f"*Total: ${total:,.2f}*") lines.append(f"Pago: {method}") lines.append("\nGracias por su compra!") - return send_message(to_phone, "\n".join(lines)) + return send_message(instance_name, to_phone, "\n".join(lines)) -def send_stock_alert(to_phone, alert_data): - """Send stock alert to owner/manager. - - Args: - to_phone: E.164 phone - alert_data: dict with keys: items[] (each has name, current_stock, min_stock) - """ +def send_stock_alert(instance_name, to_phone, alert_data): + """Send stock alert to owner/manager.""" lines = [ "*ALERTA DE INVENTARIO*", "Los siguientes articulos estan bajos en stock:", @@ -211,97 +254,90 @@ def send_stock_alert(to_phone, alert_data): lines.append(f" {name}: {current} uds (min: {minimum})") lines.append("\nRevisa el inventario en tu POS.") - return send_message(to_phone, "\n".join(lines)) + return send_message(instance_name, to_phone, "\n".join(lines)) -# ── Incoming webhook processing ────────────────────────────────────────── +# -- Incoming webhook processing ---------------------------------------------- def process_incoming(webhook_data): - """Process incoming WhatsApp webhook message. + """Process incoming Evolution API webhook. - Routes to AI chatbot for auto-response or queues for employee. - - Args: - webhook_data: the full webhook payload from Meta + Evolution sends a different format than Meta Cloud API. Returns: - dict with parsed message info: phone, text, wa_message_id, response (if auto-replied) + dict with parsed message info. """ - result = {"handled": False} + result = {'handled': False} try: - entry = webhook_data.get("entry", []) - if not entry: + data = webhook_data.get('data', {}) + key = data.get('key', {}) + message = data.get('message', {}) + + from_me = key.get('fromMe', False) + phone = key.get('remoteJid', '').replace('@s.whatsapp.net', '') + + if not phone: return result - changes = entry[0].get("changes", []) - if not changes: - return result + # Extract text from different message types + text = '' + msg_type = 'text' + if 'conversation' in message: + text = message['conversation'] + elif 'extendedTextMessage' in message: + text = message['extendedTextMessage'].get('text', '') + elif 'imageMessage' in message: + text = message['imageMessage'].get('caption', '[Imagen]') + msg_type = 'image' + elif 'documentMessage' in message: + text = message['documentMessage'].get('caption', '[Documento]') + msg_type = 'document' + elif 'audioMessage' in message: + text = '[Audio]' + msg_type = 'audio' + elif 'videoMessage' in message: + text = message['videoMessage'].get('caption', '[Video]') + msg_type = 'video' + elif 'contactMessage' in message: + text = '[Contacto]' + msg_type = 'contact' + elif 'locationMessage' in message: + text = '[Ubicacion]' + msg_type = 'location' - value = changes[0].get("value", {}) - - # Handle status updates (delivered, read, etc.) - statuses = value.get("statuses", []) - if statuses: - return { - "type": "status_update", - "statuses": statuses, - "handled": True, - } - - messages = value.get("messages", []) - if not messages: - return result - - msg = messages[0] - phone = msg.get("from", "") - wa_message_id = msg.get("id", "") - msg_type = msg.get("type", "text") - - text = "" - if msg_type == "text": - text = msg.get("text", {}).get("body", "") - elif msg_type == "interactive": - interactive = msg.get("interactive", {}) - if interactive.get("type") == "button_reply": - text = interactive.get("button_reply", {}).get("title", "") - elif interactive.get("type") == "list_reply": - text = interactive.get("list_reply", {}).get("title", "") - else: - text = f"[{msg_type} message]" - - # Contact info - contacts = value.get("contacts", []) - contact_name = "" - if contacts: - profile = contacts[0].get("profile", {}) - contact_name = profile.get("name", "") + # Contact info from pushName + contact_name = data.get('pushName', '') result = { - "type": "message", - "phone": phone, - "contact_name": contact_name, - "text": text, - "message_type": msg_type, - "wa_message_id": wa_message_id, - "handled": True, + 'type': 'message', + 'phone': phone, + 'contact_name': contact_name, + 'text': text, + 'message_type': msg_type, + 'from_me': from_me, + 'message_id': key.get('id', ''), + 'timestamp': data.get('messageTimestamp', 0), + 'handled': True, } + # Skip auto-reply for our own outgoing messages + if from_me: + result['type'] = 'outgoing' + return result + # Attempt AI auto-response if the ai_chat service is available try: from services.ai_chat import chat as ai_chat_fn ai_result = ai_chat_fn(text, []) ai_reply = ai_result.get("message", "") if ai_reply: - send_result = send_message(phone, ai_reply) result["auto_reply"] = ai_reply - result["auto_reply_wa_id"] = send_result.get("wa_message_id", "") except Exception: - # AI not available — message stays in queue for employee logger.debug("AI auto-reply not available, message queued for employee") return result except Exception as e: - logger.exception("Error processing incoming WhatsApp message") - return {"error": str(e), "handled": False} + logger.exception("Error processing incoming Evolution webhook") + return {'error': str(e), 'handled': False} diff --git a/pos/static/js/whatsapp.js b/pos/static/js/whatsapp.js index 7aea4cd..2ccdb1e 100644 --- a/pos/static/js/whatsapp.js +++ b/pos/static/js/whatsapp.js @@ -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 = { diff --git a/pos/templates/whatsapp.html b/pos/templates/whatsapp.html index c041e1f..8a9304f 100644 --- a/pos/templates/whatsapp.html +++ b/pos/templates/whatsapp.html @@ -423,6 +423,61 @@ color: var(--color-text-muted); } + /* ─── Connection / QR section ────────────────────────────────────── */ + + .connect-section { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4); + padding: var(--space-6); + text-align: center; + } + + .connect-section__title { + font-family: var(--font-heading); + font-size: var(--text-h3, 1.25rem); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + } + + .connect-section__desc { + font-size: var(--text-body-sm); + color: var(--color-text-muted); + max-width: 400px; + line-height: 1.5; + } + + .connect-section__qr { + border: 2px dashed var(--color-border); + border-radius: var(--radius-md, 8px); + padding: var(--space-4); + background: var(--color-bg-elevated); + min-width: 280px; + min-height: 280px; + display: flex; + align-items: center; + justify-content: center; + } + + .connect-section__qr img { + max-width: 256px; + max-height: 256px; + border-radius: var(--radius-sm, 4px); + } + + .connect-section__qr-placeholder { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + } + + .connect-section__actions { + display: flex; + gap: var(--space-2); + } + /* ─── Industrial theme cuts ──────────────────────────────────────── */ [data-theme="industrial"] .btn--primary { @@ -463,11 +518,34 @@ Verificando... + + - -
+ + + + +