feat: WhatsApp bridge con Baileys directo — QR funcional

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 04:02:16 +00:00
parent 5f92fe83ba
commit e43894b7a4
7 changed files with 255 additions and 672 deletions

View File

@@ -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/<id> -- Send quotation via WhatsApp (auth)
GET /pos/api/whatsapp/conversations -- List recent conversations (auth)
GET /pos/api/whatsapp/conversations/<phone> -- 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_<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
# 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/<int:quote_id>", 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/<phone>", methods=["GET"])
@whatsapp_bp.route('/conversations/<phone>', 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)})

View File

@@ -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')

View File

@@ -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', ''),
}

View File

@@ -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"
}
}

View File

@@ -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(); });