Files
Autoparts-DB/pos/services/whatsapp_service.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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
2026-10-12 00:57:31 +00:00

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)}