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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user