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:
23
docker/docker-compose.evolution.yml
Normal file
23
docker/docker-compose.evolution.yml
Normal file
@@ -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:
|
||||
38
docs/WHATSAPP-SETUP.md
Normal file
38
docs/WHATSAPP-SETUP.md
Normal file
@@ -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`
|
||||
@@ -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/<id> — Send quotation via WhatsApp (auth)
|
||||
GET /pos/api/whatsapp/conversations — List recent conversations (auth)
|
||||
GET /pos/api/whatsapp/conversations/<phone> — 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/<id> -- Send quotation via WhatsApp (auth)
|
||||
GET /pos/api/whatsapp/conversations -- List recent conversations (auth)
|
||||
GET /pos/api/whatsapp/conversations/<phone> -- 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=<token>&hub.challenge=<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_<id> 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]
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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=<your-permanent-token>
|
||||
WHATSAPP_PHONE_ID=<your-phone-number-id>
|
||||
WHATSAPP_VERIFY_TOKEN=<your-chosen-verify-string>
|
||||
5. Configure the webhook URL in Meta Developer Console:
|
||||
https://<your-domain>/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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 @@
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Verificando...</span>
|
||||
</div>
|
||||
<button class="btn btn--sm btn--whatsapp" id="connectBtn" style="display:none">Conectar WhatsApp</button>
|
||||
<button class="btn btn--sm" id="disconnectBtn" style="display:none">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messenger -->
|
||||
<div class="messenger" id="messenger">
|
||||
<!-- Connection / QR section (shown when not connected) -->
|
||||
<div class="connect-section" id="connectSection" style="display:none">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#25D366" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
</svg>
|
||||
<div class="connect-section__title">Conectar WhatsApp</div>
|
||||
<div class="connect-section__desc">
|
||||
Escanea el codigo QR con tu telefono para vincular WhatsApp.<br>
|
||||
Abre WhatsApp > Dispositivos vinculados > Vincular un dispositivo.
|
||||
</div>
|
||||
<div class="connect-section__qr">
|
||||
<img id="qrImg" style="display:none" alt="QR Code" />
|
||||
<span class="connect-section__qr-placeholder" id="qrPlaceholder">
|
||||
Haz click en "Conectar WhatsApp" para generar el QR
|
||||
</span>
|
||||
</div>
|
||||
<div class="connect-section__actions">
|
||||
<button class="btn btn--sm" id="refreshQrBtn" style="display:none">Actualizar QR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messenger (shown when connected) -->
|
||||
<div class="messenger" id="messengerArea" style="display:none">
|
||||
|
||||
<!-- Left: Conversation list -->
|
||||
<div class="conv-panel">
|
||||
|
||||
Reference in New Issue
Block a user