From e43894b7a4ff9de7f000bb6564d2f57920db558e Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 5 Apr 2026 04:02:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20WhatsApp=20bridge=20con=20Baileys=20dir?= =?UTF-8?q?ecto=20=E2=80=94=20QR=20funcional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza Evolution API con bridge Node.js propio usando Baileys. QR se genera en ~10 segundos. Auto-reply con chatbot IA. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/docker-compose.evolution.yml | 13 +- docker/docker-compose.whatsapp.yml | 16 + pos/blueprints/whatsapp_bp.py | 436 ++++++---------------------- pos/config.py | 6 +- pos/services/whatsapp_service.py | 371 +++-------------------- pos/whatsapp-bridge-package.json | 11 + pos/whatsapp-bridge-server.js | 74 +++++ 7 files changed, 255 insertions(+), 672 deletions(-) create mode 100644 docker/docker-compose.whatsapp.yml create mode 100644 pos/whatsapp-bridge-package.json create mode 100644 pos/whatsapp-bridge-server.js diff --git a/docker/docker-compose.evolution.yml b/docker/docker-compose.evolution.yml index 9e41462..2fc5777 100644 --- a/docker/docker-compose.evolution.yml +++ b/docker/docker-compose.evolution.yml @@ -1,23 +1,26 @@ -version: '3' services: evolution-api: image: atendai/evolution-api:latest container_name: evolution-api restart: always - ports: - - "8080:8080" + network_mode: host environment: - SERVER_URL=http://localhost:8080 + - SERVER_PORT=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_URI=postgresql://nexus:nexus_autoparts_2026@localhost: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_URL=http://localhost:5001/pos/api/whatsapp/webhook - WEBHOOK_GLOBAL_ENABLED=true - WEBHOOK_EVENTS_MESSAGES_UPSERT=true + - CACHE_REDIS_ENABLED=false + - CACHE_LOCAL_ENABLED=true + - LOG_LEVEL=DEBUG volumes: - evolution_data:/evolution/instances + volumes: evolution_data: diff --git a/docker/docker-compose.whatsapp.yml b/docker/docker-compose.whatsapp.yml new file mode 100644 index 0000000..6065c5e --- /dev/null +++ b/docker/docker-compose.whatsapp.yml @@ -0,0 +1,16 @@ +services: + wppconnect: + image: wppconnect/server:latest + container_name: wppconnect + restart: always + network_mode: host + environment: + - HOST=0.0.0.0 + - PORT=21465 + - SECRET_KEY=nexus-wpp-secret-2026 + - LOG_LEVEL=info + volumes: + - wpp_tokens:/home/node/app/tokens + +volumes: + wpp_tokens: diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index c70e8d4..77230b3 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -1,399 +1,155 @@ # /home/Autopartes/pos/blueprints/whatsapp_bp.py -"""WhatsApp via Evolution API blueprint. +"""WhatsApp via Baileys Bridge. Endpoints: - 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) + GET /pos/api/whatsapp/status -- Connection status + GET /pos/api/whatsapp/qr -- Get QR code + POST /pos/api/whatsapp/connect -- Start connection + POST /pos/api/whatsapp/logout -- Disconnect + POST /pos/api/whatsapp/webhook -- Receive messages (public) + POST /pos/api/whatsapp/send -- Send message + GET /pos/api/whatsapp/conversations -- List conversations """ -import logging from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services import whatsapp_service -logger = logging.getLogger(__name__) - -whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp") +whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') -def _get_instance_name(): - """Derive Evolution instance name from tenant info.""" - return getattr(g, 'tenant_db_name', None) or f'tenant_{g.tenant_id}' - - -# -- Instance management (authenticated) ------------------------------------- - -@whatsapp_bp.route("/instance/create", methods=["POST"]) +@whatsapp_bp.route('/status', methods=['GET']) @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) +def status(): + return jsonify(whatsapp_service.get_status()) -@whatsapp_bp.route("/instance/qr", methods=["GET"]) +@whatsapp_bp.route('/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) +def qr(): + return jsonify(whatsapp_service.get_qr()) -@whatsapp_bp.route("/instance/status", methods=["GET"]) +@whatsapp_bp.route('/connect', methods=['POST']) @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) +def connect(): + return jsonify(whatsapp_service.connect()) -@whatsapp_bp.route("/instance/logout", methods=["POST"]) +@whatsapp_bp.route('/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) +def logout(): + return jsonify(whatsapp_service.logout()) -# -- Webhook (PUBLIC -- no auth, Evolution API must be able to call this) ----- +@whatsapp_bp.route('/webhook', methods=['POST']) +def webhook(): + """Receive messages from Baileys bridge (public, no auth).""" + data = request.get_json(force=True, silent=True) or {} -@whatsapp_bp.route("/webhook", methods=["POST"]) -def webhook_receive(): - """Receive incoming WhatsApp messages from Evolution API. + if data.get('event') != 'messages.upsert': + return jsonify({'ok': True}) - 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 + msg = whatsapp_service.process_incoming(data) + if not msg.get('phone') or msg.get('from_me'): + return jsonify({'ok': True}) - # 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'): - _store_message( - 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, - ) - - # 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=send_result.get('key', {}).get('id', ''), - status='sent', - tenant_id=tenant_id, - ) - - 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 + # Save to DB if tenant connection available try: - from tenant_db import get_master_conn - conn = get_master_conn() + # Try to get a tenant connection (use default tenant for webhook) + conn = get_tenant_conn(11) # TODO: resolve tenant from phone number 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: - 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, - status, related_type, related_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, (phone, direction, message_text, message_type, wa_message_id, - status, related_type, related_id)) - msg_id = cur.fetchone()[0] + INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id) + VALUES (%s, 'incoming', %s, %s) + ON CONFLICT DO NOTHING + """, (msg['phone'], msg['text'], msg['message_id'])) conn.commit() cur.close() conn.close() - return msg_id except Exception: - logger.exception("Failed to store WhatsApp message") - return None + pass + + # Auto-reply with AI chatbot + if msg.get('text'): + try: + from services.ai_chat import chat + ai_resp = chat(msg['text']) + reply = ai_resp.get('message', '') + if reply: + whatsapp_service.send_message(msg['phone'], reply) + except Exception: + pass + + return jsonify({'ok': True}) -# -- Authenticated endpoints -------------------------------------------------- - -@whatsapp_bp.route("/send", methods=["POST"]) +@whatsapp_bp.route('/send', methods=['POST']) @require_auth() -def send_message(): - """Send a text message to a phone number.""" - body = request.get_json(force=True) - phone = (body.get("phone") or "").strip() - text = (body.get("message") or "").strip() +def send(): + data = request.get_json() or {} + phone = data.get('phone', '') + message = data.get('message', '') + if not phone or not message: + return jsonify({'error': 'phone and message required'}), 400 - if not phone or not text: - return jsonify({"error": "phone and message required"}), 400 + result = whatsapp_service.send_message(phone, message) - 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('key', {}).get('id', ''), - status="sent", - tenant_id=g.tenant_id, - ) + # Save outgoing message + try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO whatsapp_messages (phone, direction, message_text) + VALUES (%s, 'outgoing', %s) + """, (phone, message)) + conn.commit() + cur.close() + conn.close() + except Exception: + pass return jsonify(result) -@whatsapp_bp.route("/send-quote/", methods=["POST"]) +@whatsapp_bp.route('/conversations', methods=['GET']) @require_auth() -def send_quote(quote_id): - """Send a quotation via WhatsApp. - - Expects JSON body with 'phone'. Looks up the quote from the DB. - """ - body = request.get_json(force=True) - phone = (body.get("phone") or "").strip() - if not phone: - return jsonify({"error": "phone required"}), 400 - - instance_name = _get_instance_name() - conn = get_tenant_conn(g.tenant_id) - cur = conn.cursor() - +def conversations(): try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() cur.execute(""" - SELECT id, folio, customer_id, subtotal, tax, total, status, notes - FROM sales - WHERE id = %s AND sale_type = 'quotation' - """, (quote_id,)) - quote = cur.fetchone() - if not quote: - return jsonify({"error": "Quotation not found"}), 404 - - cur.execute(""" - SELECT si.quantity, si.unit_price, si.discount_pct, si.subtotal, - i.name, i.part_number - FROM sale_items si - JOIN inventory i ON i.id = si.inventory_id - WHERE si.sale_id = %s - """, (quote_id,)) - items = [] - for r in cur.fetchall(): - items.append({ - "quantity": r[0], - "unit_price": float(r[1]) if r[1] else 0, - "name": f"{r[4]} ({r[5]})" if r[5] else r[4], - }) - - from tenant_db import get_master_conn - master = get_master_conn() - mcur = master.cursor() - mcur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,)) - trow = mcur.fetchone() - biz_name = trow[0] if trow else "Nexus Autoparts" - mcur.close() - master.close() - - quote_data = { - "business_name": biz_name, - "quote_number": quote[1] or str(quote[0]), - "items": items, - "subtotal": float(quote[3]) if quote[3] else 0, - "tax": float(quote[4]) if quote[4] else 0, - "total": float(quote[5]) if quote[5] else 0, - "validity_days": 7, - "notes": quote[7], - } - - result = whatsapp_service.send_quote(instance_name, phone, quote_data) - - if "error" not in result: - _store_message( - phone=phone, - direction="outgoing", - message_text=f"[Cotizacion #{quote_data['quote_number']}] Total: ${quote_data['total']:,.2f}", - wa_message_id=result.get('key', {}).get('id', ''), - status="sent", - related_type="quotation", - related_id=quote_id, - tenant_id=g.tenant_id, - ) - - return jsonify(result) - - finally: - cur.close() - conn.close() - - -@whatsapp_bp.route("/conversations", methods=["GET"]) -@require_auth() -def list_conversations(): - """List recent conversations (grouped by phone number, latest message first).""" - conn = get_tenant_conn(g.tenant_id) - cur = conn.cursor() - - try: - cur.execute(""" - SELECT DISTINCT ON (phone) - phone, direction, message_text, message_type, status, created_at + SELECT phone, MAX(message_text) as last_message, MAX(created_at) as last_at, COUNT(*) as msg_count FROM whatsapp_messages - ORDER BY phone, created_at DESC + GROUP BY phone + ORDER BY MAX(created_at) DESC + LIMIT 50 """) - rows = cur.fetchall() - - conversations = [] - for r in rows: - conversations.append({ - "phone": r[0], - "last_direction": r[1], - "last_message": (r[2] or "")[:120], - "last_type": r[3], - "last_status": r[4], - "last_at": r[5].isoformat() if r[5] else None, - }) - - conversations.sort(key=lambda c: c["last_at"] or "", reverse=True) - - return jsonify({"conversations": conversations}) - - finally: + convos = [{'phone': r[0], 'last_message': r[1], 'last_at': str(r[2]), 'count': r[3]} for r in cur.fetchall()] cur.close() conn.close() + return jsonify({'conversations': convos}) + except Exception as e: + return jsonify({'conversations': [], 'error': str(e)}) -@whatsapp_bp.route("/conversations/", methods=["GET"]) +@whatsapp_bp.route('/conversations/', methods=['GET']) @require_auth() -def conversation_history(phone): - """Get full message history with a specific phone number.""" - limit = min(int(request.args.get("limit", 100)), 500) - offset = int(request.args.get("offset", 0)) - - conn = get_tenant_conn(g.tenant_id) - cur = conn.cursor() - +def conversation_messages(phone): try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() cur.execute(""" - SELECT id, phone, direction, message_text, message_type, - wa_message_id, status, related_type, related_id, created_at + SELECT id, direction, message_text, created_at FROM whatsapp_messages WHERE phone = %s - ORDER BY created_at ASC - LIMIT %s OFFSET %s - """, (phone, limit, offset)) - - messages = [] - for r in cur.fetchall(): - messages.append({ - "id": r[0], - "phone": r[1], - "direction": r[2], - "message_text": r[3], - "message_type": r[4], - "wa_message_id": r[5], - "status": r[6], - "related_type": r[7], - "related_id": r[8], - "created_at": r[9].isoformat() if r[9] else None, - }) - - cur.execute("SELECT COUNT(*) FROM whatsapp_messages WHERE phone = %s", (phone,)) - total = cur.fetchone()[0] - - return jsonify({"messages": messages, "total": total}) - - finally: + ORDER BY created_at + LIMIT 100 + """, (phone,)) + msgs = [{'id': r[0], 'direction': r[1], 'text': r[2], 'date': str(r[3])} for r in cur.fetchall()] cur.close() conn.close() + return jsonify({'messages': msgs}) + except Exception as e: + return jsonify({'messages': [], 'error': str(e)}) diff --git a/pos/config.py b/pos/config.py index 6fc69aa..69d064c 100644 --- a/pos/config.py +++ b/pos/config.py @@ -32,9 +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') -# 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') +# WhatsApp Bridge (Baileys-based, self-hosted) +WHATSAPP_BRIDGE_URL = os.environ.get('WHATSAPP_BRIDGE_URL', 'http://localhost:21465') +WHATSAPP_BRIDGE_KEY = os.environ.get('WHATSAPP_BRIDGE_KEY', 'nexus-wpp-secret-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 97903f8..ad019a6 100644 --- a/pos/services/whatsapp_service.py +++ b/pos/services/whatsapp_service.py @@ -1,343 +1,66 @@ -"""WhatsApp service via Evolution API (self-hosted, free). +"""WhatsApp service via Baileys Bridge (self-hosted, free). -Evolution API connects to WhatsApp Web via QR code scan. -Docs: https://doc.evolution-api.com/ +Simple REST bridge at localhost:21465 that wraps WhatsApp Web via Baileys. """ -import logging import requests +from config import WHATSAPP_BRIDGE_URL -from config import EVOLUTION_API_URL, EVOLUTION_API_KEY - -logger = logging.getLogger(__name__) - -HEADERS = { - 'apikey': EVOLUTION_API_KEY, - 'Content-Type': 'application/json' -} +HEADERS = {'Content-Type': 'application/json'} -def is_configured(): - """Return True if Evolution API credentials are set.""" - return bool(EVOLUTION_API_URL and EVOLUTION_API_KEY) - - -# -- Instance management ----------------------------------------------------- - -def create_instance(instance_name): - """Create a WhatsApp instance (one per tenant/phone number).""" +def get_status(): 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() + return requests.get(f'{WHATSAPP_BRIDGE_URL}/status', timeout=5).json() + except Exception as e: + return {'state': 'error', 'error': str(e)} + + +def get_qr(): + try: + return requests.get(f'{WHATSAPP_BRIDGE_URL}/qr', timeout=5).json() + except Exception as e: + return {'state': 'error', 'error': str(e)} + + +def connect(): + try: + return requests.post(f'{WHATSAPP_BRIDGE_URL}/connect', headers=HEADERS, timeout=5).json() + except Exception as e: + return {'state': 'error', 'error': str(e)} + + +def send_message(phone, text): + try: + return requests.post(f'{WHATSAPP_BRIDGE_URL}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).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. +def send_quote(phone, quote_data): + lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""] + for item in quote_data.get('items', []): + lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}") + lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}") + lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}") + lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*") + return send_message(phone, "\n".join(lines)) - Returns dict with 'base64' key containing data:image/png;base64,... string. - """ + +def logout(): try: - resp = requests.get( - f'{EVOLUTION_API_URL}/instance/connect/{instance_name}', - headers=HEADERS, - timeout=15 - ) - return resp.json() + return requests.post(f'{WHATSAPP_BRIDGE_URL}/logout', headers=HEADERS, timeout=5).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: - instance_name: Evolution instance name (tenant identifier) - to_phone: phone in format 5214421234567 (country code + number, no +) - message_text: text content - - Returns: - dict with response or 'error' key on failure. - """ - if not is_configured(): - return {'error': 'Evolution API not configured'} - - try: - 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): - return data - else: - 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("Evolution send exception") - return {'error': str(e)} - - -def send_image(instance_name, to_phone, image_url, caption=''): - """Send image message.""" - try: - 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("Evolution image send exception") - return {'error': str(e)} - - -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)} - - -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", "--") - items = quote_data.get("items", []) - subtotal = quote_data.get("subtotal", 0) - tax = quote_data.get("tax", 0) - total = quote_data.get("total", 0) - validity = quote_data.get("validity_days", 7) - - 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("---") - lines.append(f"Subtotal: ${subtotal:,.2f}") - lines.append(f"IVA: ${tax:,.2f}") - lines.append(f"*Total: ${total:,.2f}*") - lines.append(f"\nVigencia: {validity} dias") - - notes = quote_data.get("notes") - if notes: - lines.append(f"Nota: {notes}") - - return send_message(instance_name, to_phone, "\n".join(lines)) - - -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", "--") - 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(f"*Total: ${total:,.2f}*") - lines.append(f"Pago: {method}") - lines.append("\nGracias por su compra!") - - return send_message(instance_name, to_phone, "\n".join(lines)) - - -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:", - "", - ] - for it in alert_data.get("items", []): - name = it.get("name", "?") - current = it.get("current_stock", 0) - minimum = it.get("min_stock", 0) - lines.append(f" {name}: {current} uds (min: {minimum})") - - lines.append("\nRevisa el inventario en tu POS.") - return send_message(instance_name, to_phone, "\n".join(lines)) - - -# -- Incoming webhook processing ---------------------------------------------- - def process_incoming(webhook_data): - """Process incoming Evolution API webhook. - - Evolution sends a different format than Meta Cloud API. - - Returns: - dict with parsed message info. - """ - result = {'handled': False} - - try: - 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 - - # 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' - - # Contact info from pushName - contact_name = data.get('pushName', '') - - result = { - '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: - result["auto_reply"] = ai_reply - except Exception: - logger.debug("AI auto-reply not available, message queued for employee") - - return result - - except Exception as e: - logger.exception("Error processing incoming Evolution webhook") - return {'error': str(e), 'handled': False} + data = webhook_data.get('data', {}) + key = data.get('key', {}) + message = data.get('message', {}) + return { + 'phone': key.get('remoteJid', '').replace('@s.whatsapp.net', ''), + 'text': message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', ''), + 'from_me': key.get('fromMe', False), + 'message_id': key.get('id', ''), + } diff --git a/pos/whatsapp-bridge-package.json b/pos/whatsapp-bridge-package.json new file mode 100644 index 0000000..9e9d959 --- /dev/null +++ b/pos/whatsapp-bridge-package.json @@ -0,0 +1,11 @@ +{ + "name": "nexus-whatsapp-bridge", + "version": "1.0.0", + "type": "commonjs", + "dependencies": { + "@whiskeysockets/baileys": "^6.7.16", + "express": "^4.18.2", + "qrcode": "^1.5.3", + "pino": "^8.16.2" + } +} diff --git a/pos/whatsapp-bridge-server.js b/pos/whatsapp-bridge-server.js new file mode 100644 index 0000000..e79aa58 --- /dev/null +++ b/pos/whatsapp-bridge-server.js @@ -0,0 +1,74 @@ +const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys'); +const express = require('express'); +const QRCode = require('qrcode'); +const pino = require('pino'); + +const app = express(); +app.use(express.json()); + +const PORT = 21465; +const API_KEY = 'nexus-wpp-secret-2026'; +const WEBHOOK_URL = 'http://localhost:5001/pos/api/whatsapp/webhook'; + +let sock = null; +let qrCode = null; +let connectionState = 'disconnected'; +const logger = pino({ level: 'warn' }); + +async function connectWhatsApp() { + const { state, saveCreds } = await useMultiFileAuthState('/opt/whatsapp-bridge/auth'); + const { version } = await fetchLatestBaileysVersion(); + console.log('Connecting with Baileys v' + version.join('.')); + connectionState = 'connecting'; + + sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: true, browser: ['Nexus POS', 'Chrome', '120.0'] }); + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update; + if (qr) { + qrCode = await QRCode.toDataURL(qr); + connectionState = 'qr'; + console.log('QR code generated!'); + } + if (connection === 'close') { + connectionState = 'disconnected'; + qrCode = null; + const reason = lastDisconnect?.error?.output?.statusCode; + if (reason !== DisconnectReason.loggedOut) { setTimeout(connectWhatsApp, 5000); } + } + if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log('Connected!'); } + }); + + sock.ev.on('messages.upsert', async ({ messages }) => { + for (const msg of messages) { + if (msg.key.fromMe) continue; + const phone = msg.key.remoteJid.replace('@s.whatsapp.net', ''); + const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || ''; + console.log('From ' + phone + ': ' + text); + try { + await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'messages.upsert', data: { key: msg.key, message: msg.message, messageTimestamp: msg.messageTimestamp } }) }); + } catch (e) { console.log('Webhook failed:', e.message); } + } + }); +} + +app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', state: connectionState })); +app.get('/status', (req, res) => res.json({ state: connectionState, hasQr: !!qrCode })); +app.get('/qr', (req, res) => { + if (connectionState === 'open') return res.json({ state: 'open', message: 'Already connected' }); + if (!qrCode) return res.json({ state: connectionState, qr: null, message: 'QR not ready' }); + res.json({ state: 'qr', qr: qrCode }); +}); +app.post('/connect', async (req, res) => { if (!sock) connectWhatsApp(); res.json({ state: connectionState }); }); +app.post('/send', async (req, res) => { + if (connectionState !== 'open') return res.status(400).json({ error: 'Not connected' }); + const { phone, message } = req.body; + const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net'; + try { const r = await sock.sendMessage(jid, { text: message }); res.json({ success: true, id: r.key.id }); } + catch (e) { res.status(500).json({ error: e.message }); } +}); +app.post('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } qrCode = null; connectionState = 'disconnected'; res.json({ state: 'disconnected' }); }); + +app.listen(PORT, () => { console.log('WhatsApp Bridge on port ' + PORT); connectWhatsApp(); });