- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
460 lines
19 KiB
Python
460 lines
19 KiB
Python
"""WhatsApp service via Baileys Bridge (self-hosted, free).
|
|
|
|
Simple REST bridge that wraps WhatsApp Web via Baileys.
|
|
Supports per-tenant configuration via bridge_url parameter.
|
|
"""
|
|
|
|
import requests
|
|
from config import WHATSAPP_BRIDGE_URL
|
|
|
|
HEADERS = {'Content-Type': 'application/json'}
|
|
|
|
|
|
def _get_url(bridge_url=None):
|
|
return bridge_url or WHATSAPP_BRIDGE_URL
|
|
|
|
|
|
def get_status(bridge_url=None):
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.get(f'{url}/status', timeout=5).json()
|
|
except Exception as e:
|
|
return {'state': 'error', 'error': str(e)}
|
|
|
|
|
|
def get_qr(bridge_url=None):
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.get(f'{url}/qr', timeout=5).json()
|
|
except Exception as e:
|
|
return {'state': 'error', 'error': str(e)}
|
|
|
|
|
|
def connect(bridge_url=None):
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.post(f'{url}/connect', headers=HEADERS, timeout=5).json()
|
|
except Exception as e:
|
|
return {'state': 'error', 'error': str(e)}
|
|
|
|
|
|
def send_message(phone, text, bridge_url=None):
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.post(f'{url}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json()
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def send_quote(phone, quote_data, bridge_url=None):
|
|
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), bridge_url=bridge_url)
|
|
|
|
|
|
def logout(bridge_url=None):
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.post(f'{url}/logout', headers=HEADERS, timeout=5).json()
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def process_incoming(webhook_data):
|
|
"""Extract a normalized dict from a Baileys webhook payload.
|
|
|
|
Supports text messages, image messages, audio (voice notes), and video.
|
|
Media content comes pre-downloaded as base64 from the bridge so Python
|
|
doesn't have to re-authenticate with WhatsApp servers.
|
|
|
|
Returns:
|
|
dict with keys:
|
|
phone — numeric phone (no JID suffix)
|
|
jid — full remote JID (may be @s.whatsapp.net or @lid)
|
|
text — text content (plain text or media caption)
|
|
from_me — bool, True if we sent the message
|
|
message_id — WhatsApp message ID
|
|
media_kind — 'text' | 'image' | 'audio' | 'video'
|
|
media_base64 — base64 string if media, else None
|
|
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
|
|
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
|
|
push_name — display name from WhatsApp
|
|
"""
|
|
data = webhook_data.get('data', {})
|
|
key = data.get('key', {})
|
|
message = data.get('message', {})
|
|
|
|
# remoteJid can be phone@s.whatsapp.net or LID:instance@lid
|
|
remote_jid = key.get('remoteJid', '')
|
|
# Strip JID suffixes and LID instance suffix (:12)
|
|
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
|
|
|
|
# DEBUG
|
|
import json
|
|
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
|
|
|
|
# senderPn contains the real phone number when remoteJid is a privacy LID
|
|
sender_pn = key.get('senderPn', '')
|
|
if sender_pn:
|
|
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
|
|
|
|
# The bridge now classifies and passes these extra fields. Fall back to
|
|
# the old parsing if they're missing (older bridge version).
|
|
media_kind = data.get('media_kind', 'text')
|
|
media_base64 = data.get('media_base64')
|
|
media_mimetype = data.get('media_mimetype')
|
|
media_caption = data.get('media_caption') or ''
|
|
is_voice_note = bool(data.get('media_ptt'))
|
|
push_name = data.get('push_name') or ''
|
|
|
|
# Text content:
|
|
# - For 'text' messages → conversation or extendedTextMessage
|
|
# - For 'image'/'video' → the caption (may be empty)
|
|
# - For 'audio' → empty (filled in later by Whisper transcription)
|
|
# - For 'location' → synthetic text with coordinates
|
|
if media_kind == 'text':
|
|
text = (
|
|
message.get('conversation', '')
|
|
or message.get('extendedTextMessage', {}).get('text', '')
|
|
or ''
|
|
)
|
|
else:
|
|
text = media_caption
|
|
|
|
# Location fields (from bridge classification)
|
|
latitude = data.get('latitude')
|
|
longitude = data.get('longitude')
|
|
|
|
return {
|
|
'phone': phone,
|
|
'jid': remote_jid,
|
|
'sender_pn': sender_pn,
|
|
'text': text,
|
|
'from_me': key.get('fromMe', False),
|
|
'message_id': key.get('id', ''),
|
|
'media_kind': media_kind,
|
|
'media_base64': media_base64,
|
|
'media_mimetype': media_mimetype,
|
|
'is_voice_note': is_voice_note,
|
|
'push_name': push_name,
|
|
'latitude': latitude,
|
|
'longitude': longitude,
|
|
}
|
|
|
|
|
|
def send_image(phone, caption, base64_image, bridge_url=None):
|
|
"""Send an image message via the Baileys bridge."""
|
|
url = _get_url(bridge_url)
|
|
try:
|
|
return requests.post(
|
|
f'{url}/send-image',
|
|
headers=HEADERS,
|
|
json={'phone': phone, 'caption': caption, 'base64': base64_image},
|
|
timeout=15
|
|
).json()
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Meta Cloud API provider (WA CRM / self-hosted, 1 número por tenant)
|
|
#
|
|
# cfg keys (tenant_config):
|
|
# whatsapp_provider = 'meta' | 'baileys' (default: baileys)
|
|
# wa_phone_number_id — Meta phone number ID
|
|
# wa_access_token — permanent/long-lived access token
|
|
# wa_app_secret — App secret (valida X-Hub-Signature-256 en webhooks)
|
|
# wa_verify_token — token del GET verify del webhook
|
|
# wa_graph_url — (opcional) override de graph.facebook.com/v21.0
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
import base64 as _b64
|
|
import secrets as _secrets
|
|
|
|
|
|
def _meta_is_meta(cfg):
|
|
return str(cfg.get('provider') or '').lower() == 'meta' and cfg.get('phone_number_id')
|
|
|
|
|
|
def _meta_digits(phone):
|
|
return ''.join(ch for ch in str(phone or '') if ch.isdigit())
|
|
|
|
|
|
def _meta_graph(cfg, method, path, json_payload=None, files=None, data=None, params=None):
|
|
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
|
|
url = f"{base}/{path}"
|
|
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
|
|
if method == 'GET':
|
|
return requests.get(url, headers=headers, params=params or {}, timeout=20).json()
|
|
if files:
|
|
return requests.post(url, headers={'Authorization': headers['Authorization']},
|
|
files=files, data=data or {}, timeout=60).json()
|
|
return requests.post(url, headers=headers, json=json_payload, timeout=30).json()
|
|
|
|
|
|
def meta_send_text(cfg, phone, text):
|
|
try:
|
|
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
|
'messaging_product': 'whatsapp',
|
|
'recipient_type': 'individual',
|
|
'to': _meta_digits(phone),
|
|
'type': 'text',
|
|
'text': {'preview_url': True, 'body': text},
|
|
})
|
|
if r.get('error'):
|
|
return {'error': r['error'].get('message', 'Meta error')}
|
|
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def meta_send_image(cfg, phone, caption, base64_image):
|
|
"""Upload media then send image message."""
|
|
try:
|
|
raw = base64_image
|
|
if ',' in raw and raw.strip().startswith('data:'):
|
|
raw = raw.split(',', 1)[1]
|
|
img_bytes = _b64.b64decode(raw)
|
|
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
|
|
files={'file': ('image.jpg', img_bytes, 'image/jpeg')},
|
|
data={'messaging_product': 'whatsapp'})
|
|
media_id = up.get('id')
|
|
if not media_id:
|
|
return {'error': up.get('error', {}).get('message', 'media upload failed')}
|
|
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
|
'messaging_product': 'whatsapp',
|
|
'recipient_type': 'individual',
|
|
'to': _meta_digits(phone),
|
|
'type': 'image',
|
|
'image': {'id': media_id, 'caption': caption or ''},
|
|
})
|
|
if r.get('error'):
|
|
return {'error': r['error'].get('message', 'Meta error')}
|
|
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def meta_get_status(cfg):
|
|
try:
|
|
r = _meta_graph(cfg, 'GET', f"{cfg['phone_number_id']}",
|
|
params={'fields': 'display_phone_number,verified_name,quality_rating'})
|
|
if r.get('error'):
|
|
return {'state': 'error', 'error': r['error'].get('message')}
|
|
return {'state': 'connected', 'phone': r.get('display_phone_number'),
|
|
'name': r.get('verified_name'), 'quality': r.get('quality_rating')}
|
|
except Exception as e:
|
|
return {'state': 'error', 'error': str(e)}
|
|
|
|
|
|
def meta_verify_signature(app_secret, payload_bytes, signature_header):
|
|
"""Validate X-Hub-Signature-256: 'sha256=<hex>'."""
|
|
if not app_secret or not signature_header:
|
|
return True # not configured -> do not enforce
|
|
import hmac as _hmac
|
|
import hashlib as _hashlib
|
|
expected = 'sha256=' + _hmac.new(
|
|
app_secret.encode(), payload_bytes, _hashlib.sha256).hexdigest()
|
|
try:
|
|
return _hmac.compare_digest(expected, signature_header)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _provider_cfg_error(cfg, provider):
|
|
"""Error claro cuando el proveedor elegido está incompleto (sin fallback silencioso)."""
|
|
if provider == 'wacrm':
|
|
return {'error': 'WA CRM no configurado: faltan api_base y/o api_key (Configuración → WhatsApp)'}
|
|
if provider == 'meta':
|
|
return {'error': 'Meta Cloud API no configurada: faltan phone_number_id y/o access_token'}
|
|
if provider == 'baileys':
|
|
return {'error': 'Bridge de WhatsApp no configurado o retirado (WhatsApp pasa por WA CRM)'}
|
|
return {'error': f'Proveedor de WhatsApp desconocido: {provider}'}
|
|
|
|
|
|
def wa_send_text(cfg, phone, text):
|
|
"""Router: WA CRM, Meta directo o Baileys según config del tenant.
|
|
|
|
Si el proveedor elegido está incompleto, devuelve un error explícito en
|
|
lugar de degradar silenciosamente a otro proveedor.
|
|
"""
|
|
cfg = cfg or {}
|
|
provider = str(cfg.get('provider') or 'baileys').lower()
|
|
if provider == 'wacrm':
|
|
if not _wacrm_is_wacrm(cfg):
|
|
return _provider_cfg_error(cfg, 'wacrm')
|
|
return wacrm_send_text(cfg, phone, text)
|
|
if provider == 'meta':
|
|
if not _meta_is_meta(cfg):
|
|
return _provider_cfg_error(cfg, 'meta')
|
|
return meta_send_text(cfg, phone, text)
|
|
if not cfg.get('bridge_url'):
|
|
return _provider_cfg_error(cfg, 'baileys')
|
|
return send_message(phone, text, bridge_url=cfg.get('bridge_url'))
|
|
|
|
|
|
def wa_send_image(cfg, phone, caption, base64_image):
|
|
cfg = cfg or {}
|
|
provider = str(cfg.get('provider') or 'baileys').lower()
|
|
if provider == 'wacrm' and not _wacrm_is_wacrm(cfg):
|
|
return _provider_cfg_error(cfg, 'wacrm')
|
|
if provider == 'meta' and not _meta_is_meta(cfg):
|
|
return _provider_cfg_error(cfg, 'meta')
|
|
if provider == 'baileys' and not cfg.get('bridge_url'):
|
|
return _provider_cfg_error(cfg, 'baileys')
|
|
if _wacrm_is_wacrm(cfg):
|
|
# WA CRM exige URL pública para media; sin ella degradamos a texto.
|
|
public_base = (cfg.get('public_base') or '').rstrip('/')
|
|
if public_base:
|
|
import uuid as _uuid
|
|
import os as _os
|
|
token = _uuid.uuid4().hex
|
|
media_dir = '/tmp/wa_media'
|
|
_os.makedirs(media_dir, exist_ok=True)
|
|
raw = base64_image.split(',', 1)[1] if base64_image.strip().startswith('data:') else base64_image
|
|
path = _os.path.join(media_dir, token + '.png')
|
|
try:
|
|
with open(path, 'wb') as fh:
|
|
fh.write(_b64.b64decode(raw))
|
|
except Exception:
|
|
return {'error': 'no se pudo preparar la imagen'}
|
|
return wacrm_send_media(cfg, phone, 'image',
|
|
f'{public_base}/pos/api/public/media/{token}.png', caption)
|
|
return wa_send_text(cfg, phone, (caption or '') + '\n(imagen no disponible: configura wa_public_base)')
|
|
if _meta_is_meta(cfg or {}):
|
|
return meta_send_image(cfg, phone, caption, base64_image)
|
|
return send_image(phone, caption, base64_image, bridge_url=(cfg or {}).get('bridge_url'))
|
|
|
|
|
|
def wa_send_document(cfg, phone, base64_data, filename, caption=''):
|
|
"""Envía PDF/documento por el proveedor configurado (WA CRM, Meta o Baileys)."""
|
|
cfg = cfg or {}
|
|
provider = str(cfg.get('provider') or 'baileys').lower()
|
|
if provider == 'wacrm':
|
|
if not _wacrm_is_wacrm(cfg):
|
|
return _provider_cfg_error(cfg, 'wacrm')
|
|
if not (cfg.get('public_base') or '').strip():
|
|
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
|
|
if _wacrm_is_wacrm(cfg):
|
|
public_base = (cfg.get('public_base') or '').rstrip('/')
|
|
if not public_base:
|
|
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
|
|
import uuid as _uuid
|
|
import os as _os
|
|
token = _uuid.uuid4().hex
|
|
media_dir = '/tmp/wa_media'
|
|
_os.makedirs(media_dir, exist_ok=True)
|
|
raw = base64_data.split(',', 1)[1] if base64_data.strip().startswith('data:') else base64_data
|
|
path = _os.path.join(media_dir, token + '_' + filename)
|
|
with open(path, 'wb') as fh:
|
|
fh.write(_b64.b64decode(raw))
|
|
return wacrm_send_media(cfg, phone, 'document',
|
|
f'{public_base}/pos/api/public/media/{token}_{filename}',
|
|
caption, filename)
|
|
try:
|
|
raw = base64_data
|
|
if ',' in raw and raw.strip().startswith('data:'):
|
|
raw = raw.split(',', 1)[1]
|
|
if _meta_is_meta(cfg):
|
|
img_bytes = _b64.b64decode(raw)
|
|
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
|
|
files={'file': (filename, img_bytes, 'application/pdf')},
|
|
data={'messaging_product': 'whatsapp'})
|
|
media_id = up.get('id')
|
|
if not media_id:
|
|
return {'error': up.get('error', {}).get('message', 'media upload failed')}
|
|
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
|
|
'messaging_product': 'whatsapp',
|
|
'recipient_type': 'individual',
|
|
'to': _meta_digits(phone),
|
|
'type': 'document',
|
|
'document': {'id': media_id, 'filename': filename, 'caption': caption or ''},
|
|
})
|
|
if r.get('error'):
|
|
return {'error': r['error'].get('message', 'Meta error')}
|
|
return {'ok': True}
|
|
# Baileys bridge: endpoint genérico send-file (best effort)
|
|
url = _get_url(cfg.get('bridge_url'))
|
|
r = requests.post(
|
|
f'{url}/send-file',
|
|
headers=HEADERS,
|
|
json={'phone': phone, 'base64': raw, 'filename': filename, 'caption': caption or ''},
|
|
timeout=30,
|
|
).json()
|
|
return r if isinstance(r, dict) else {'ok': True}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Proveedor WA CRM (self-hosted) — API pública /api/v1
|
|
# cfg: provider='wacrm', api_base, api_key (+ public_base opcional)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _wacrm_is_wacrm(cfg):
|
|
return str((cfg or {}).get('provider') or '').lower() == 'wacrm' and (cfg or {}).get('api_base') and (cfg or {}).get('api_key')
|
|
|
|
|
|
def _wacrm_e164(phone):
|
|
digits = ''.join(ch for ch in str(phone or '') if ch.isdigit())
|
|
if not digits:
|
|
return ''
|
|
if digits.startswith('52'):
|
|
return '+' + digits
|
|
if len(digits) == 10:
|
|
return '+52' + digits
|
|
return '+' + digits
|
|
|
|
|
|
def wacrm_send_text(cfg, phone, text):
|
|
base = (cfg.get('api_base') or '').rstrip('/')
|
|
try:
|
|
r = requests.post(
|
|
f'{base}/api/v1/messages',
|
|
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
|
|
json={'to': _wacrm_e164(phone), 'type': 'text', 'text': text},
|
|
timeout=30,
|
|
)
|
|
data = r.json() if r.content else {}
|
|
if r.status_code >= 400 or data.get('error'):
|
|
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
|
|
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def wacrm_send_media(cfg, phone, kind, media_url, caption='', filename=None):
|
|
base = (cfg.get('api_base') or '').rstrip('/')
|
|
payload = {'to': _wacrm_e164(phone), 'type': kind, 'media_url': media_url, 'text': caption or ''}
|
|
if filename:
|
|
payload['filename'] = filename
|
|
try:
|
|
r = requests.post(
|
|
f'{base}/api/v1/messages',
|
|
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
|
|
json=payload,
|
|
timeout=45,
|
|
)
|
|
data = r.json() if r.content else {}
|
|
if r.status_code >= 400 or data.get('error'):
|
|
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
|
|
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def wacrm_get_status(cfg):
|
|
base = (cfg.get('api_base') or '').rstrip('/')
|
|
try:
|
|
r = requests.get(f'{base}/api/v1/me',
|
|
headers={'Authorization': f"Bearer {cfg.get('api_key')}"}, timeout=15)
|
|
if r.status_code != 200:
|
|
return {'state': 'error', 'error': f'HTTP {r.status_code}'}
|
|
return {'state': 'connected', 'data': r.json().get('data', {})}
|
|
except Exception as e:
|
|
return {'state': 'error', 'error': str(e)}
|