Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -34,6 +34,7 @@ def _get_whatsapp_config(conn):
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
provider = (config.get('whatsapp_provider') or 'baileys').lower()
enabled_raw = config.get('whatsapp_enabled', '').lower()
if enabled_raw == 'true':
enabled = True
@@ -43,7 +44,17 @@ def _get_whatsapp_config(conn):
# No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url)
return {
return { 'provider': provider,
'phone_number_id': config.get('wa_phone_number_id', ''),
'access_token': config.get('wa_access_token', ''),
'app_secret': config.get('wa_app_secret', ''),
'verify_token': config.get('wa_verify_token', ''),
'graph_url': config.get('wa_graph_url', ''),
'ai_persona': config.get('wa_ai_persona', ''),
'api_base': config.get('wa_api_base', ''),
'api_key': config.get('wa_api_key', ''),
'public_base': config.get('wa_public_base', ''),
'bridge_url': bridge_url,
'bridge_key': bridge_key,
'enabled': enabled,
@@ -359,6 +370,11 @@ def webhook():
return jsonify({'ok': True})
msg = whatsapp_service.process_incoming(data)
return _process_incoming_whatsapp_msg(msg)
def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
"""Procesa un mensaje entrante normalizado (Baileys o Meta Cloud API)."""
if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True})
@@ -393,7 +409,7 @@ def webhook():
# Resolve tenant
tenant_id = request.args.get('tenant_id', type=int)
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
if not tenant_id:
try:
mconn = get_master_conn()
@@ -494,7 +510,7 @@ def webhook():
from services.part_kits import should_send_followup
followup = should_send_followup(phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
whatsapp_service.wa_send_text(wa_config, reply_to, followup)
cur_fu = tenant_conn.cursor()
cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
@@ -544,7 +560,7 @@ def webhook():
# 7. Send reply
if reply:
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
# Log outgoing
@@ -568,7 +584,7 @@ def webhook():
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
f"Por favor llámanos directamente al {phone_branch}."
)
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
except Exception:
pass
@@ -583,10 +599,221 @@ def webhook():
master_conn.close()
except Exception:
pass
return jsonify({'ok': True})
# ═══════════════════════════════════════════════════════════════════════════
# WA CRM / Meta Cloud API webhook (1 número por refaccionaria)
# ═══════════════════════════════════════════════════════════════════════════
import os as _os_wa
def _resolve_tenant_by_pnid(phone_number_id):
"""Resolve (tenant_id, wa_config) by Meta phone_number_id. Cached per process."""
if not phone_number_id:
return None, None
cache = getattr(_resolve_tenant_by_pnid, '_cache', None)
if cache is None:
cache = _resolve_tenant_by_pnid._cache = {}
if phone_number_id in cache:
return cache[phone_number_id]
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
tenants = mcur.fetchall()
mcur.close()
mconn.close()
except Exception:
return None, None
from tenant_db import get_tenant_conn_by_dbname
for tid, db_name in tenants:
try:
tconn = get_tenant_conn_by_dbname(db_name)
cfg = _get_whatsapp_config(tconn)
tconn.close()
if str(cfg.get('phone_number_id') or '') == str(phone_number_id):
cache[phone_number_id] = (tid, cfg)
return tid, cfg
except Exception:
continue
cache[phone_number_id] = (None, None)
return None, None
def _meta_download_media_b64(cfg, media_id):
"""Download Meta media (audio/image/document) → base64 + mimetype."""
import requests as _rq
import base64 as _b64
try:
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
info = _rq.get(f"{base}/{media_id}", headers=headers, timeout=20).json()
url = info.get('url')
if not url:
return None, None
data = _rq.get(url, headers=headers, timeout=60).content
return _b64.b64encode(data).decode(), info.get('mime_type', 'application/octet-stream')
except Exception as e:
print(f"[WA-CRM] media download failed: {e}")
return None, None
def _meta_parse_msg(payload, cfg):
"""Parse a Meta Cloud API webhook payload into the internal msg dict.
Returns (phone_number_id, msg) — msg may be None for non-message events."""
pnid = None
for entry in payload.get('entry', []):
for change in entry.get('changes', []):
value = change.get('value', {})
pnid = value.get('metadata', {}).get('phone_number_id', pnid)
if not value.get('messages'):
continue
m = value['messages'][0]
contact = (value.get('contacts') or [{}])[0]
push_name = (contact.get('profile') or {}).get('name', '')
msg = {
'phone': m.get('from', ''),
'from_me': False,
'message_id': m.get('id', ''),
'push_name': push_name,
'text': '',
'media_kind': 'text',
}
mtype = m.get('type', '')
if mtype == 'text':
msg['text'] = m.get('text', {}).get('body', '')
elif mtype == 'image':
msg['media_kind'] = 'image'
msg['text'] = m.get('image', {}).get('caption', '') or '(imagen)'
b64, mt = _meta_download_media_b64(cfg, m.get('image', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt
elif mtype == 'audio' or mtype == 'voice':
msg['media_kind'] = 'audio'
b64, mt = _meta_download_media_b64(cfg, m.get('audio', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt or 'audio/ogg'
elif mtype == 'document':
msg['media_kind'] = 'image'
msg['text'] = m.get('document', {}).get('caption', '') or '(documento)'
b64, mt = _meta_download_media_b64(cfg, m.get('document', {}).get('id'))
if b64:
msg['media_base64'] = b64
msg['media_mimetype'] = mt
elif mtype == 'location':
lat = m.get('location', {}).get('latitude')
lng = m.get('location', {}).get('longitude')
msg['media_kind'] = 'location'
msg['latitude'] = lat
msg['longitude'] = lng
return pnid, msg
return pnid, None
@whatsapp_bp.route('/wacrm/webhook', methods=['GET'])
def wacrm_webhook_verify():
"""Meta webhook verification handshake."""
verify_token = request.args.get('hub.verify_token', '')
challenge = request.args.get('hub.challenge', '')
if not challenge:
return 'Bad request', 400
expected_global = _os_wa.environ.get('WA_VERIFY_TOKEN', '')
if expected_global and verify_token == expected_global:
return challenge, 200
# per-tenant verify tokens
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
tenants = mcur.fetchall()
mcur.close()
mconn.close()
from tenant_db import get_tenant_conn_by_dbname
for tid, db_name in tenants:
try:
tconn = get_tenant_conn_by_dbname(db_name)
cur = tconn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'wa_verify_token'")
row = cur.fetchone()
tconn.close()
if row and row[0] and verify_token == row[0]:
return challenge, 200
except Exception:
continue
except Exception:
pass
return 'Forbidden', 403
@whatsapp_bp.route('/wacrm/webhook', methods=['POST'])
def wacrm_webhook():
"""Meta Cloud API events (WA CRM self-hosted, 1 número por tenant)."""
raw = request.get_data()
payload = request.get_json(silent=True) or {}
signature = request.headers.get('X-Hub-Signature-256', '')
# First parse to get phone_number_id (to resolve tenant + its app secret)
pnid_probe = None
for entry in payload.get('entry', []):
for change in entry.get('changes', []):
pnid_probe = change.get('value', {}).get('metadata', {}).get('phone_number_id')
break
if pnid_probe:
break
tenant_id, wa_cfg = _resolve_tenant_by_pnid(pnid_probe) if pnid_probe else (None, None)
app_secret = (wa_cfg or {}).get('app_secret') or _os_wa.environ.get('WA_APP_SECRET', '')
from services.whatsapp_service import meta_verify_signature
if not meta_verify_signature(app_secret, raw, signature):
return 'Invalid signature', 403
pnid, msg = _meta_parse_msg(payload, wa_cfg or {})
# Formato de automatización de WA CRM: {"phone": "...", "message": "..."} (o from/text)
if not msg:
raw_phone = payload.get('phone') or payload.get('from') or payload.get('wa_id')
raw_text = payload.get('message') or payload.get('text') or payload.get('body')
if raw_phone and raw_text is not None:
msg = {
'phone': str(raw_phone),
'from_me': bool(payload.get('from_me')),
'message_id': payload.get('message_id') or payload.get('id') or '',
'push_name': payload.get('name') or payload.get('push_name') or '',
'text': str(raw_text),
'media_kind': 'text',
}
if not msg:
return jsonify({'ok': True}) # statuses / reads / non-message events
if not tenant_id:
tid_arg = request.args.get('tenant_id', type=int)
if tid_arg:
from tenant_db import get_tenant_conn as _gtc
try:
_c = _gtc(tid_arg)
wa_cfg = _get_whatsapp_config(_c)
_c.close()
tenant_id = tid_arg
except Exception:
tenant_id = None
if not tenant_id:
print(f"[WA-CRM] No tenant for phone_number_id={pnid_probe}")
return jsonify({'ok': True})
return _process_incoming_whatsapp_msg(msg, tenant_id_override=tenant_id)
@whatsapp_bp.route('/send', methods=['POST'])
@require_auth()
def send():