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
This commit is contained in:
@@ -158,3 +158,302 @@ def send_image(phone, caption, base64_image, bridge_url=None):
|
||||
).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)}
|
||||
|
||||
Reference in New Issue
Block a user