Files
Autoparts-DB/pos/blueprints/whatsapp_bp.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

961 lines
36 KiB
Python

# /home/Autopartes/pos/blueprints/whatsapp_bp.py
"""WhatsApp via Baileys Bridge.
Endpoints:
GET /pos/api/whatsapp/status -- Connection status
GET /pos/api/whatsapp/qr -- Get QR code
POST /pos/api/whatsapp/connect -- Start connection
POST /pos/api/whatsapp/logout -- Disconnect
POST /pos/api/whatsapp/webhook -- Receive messages (public)
POST /pos/api/whatsapp/send -- Send message
GET /pos/api/whatsapp/conversations -- List conversations
"""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
from datetime import datetime
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _get_whatsapp_config(conn):
"""Read WhatsApp bridge configuration from tenant_config.
Falls back to global server config (config.py / env vars) when tenant
has no explicit WhatsApp settings. This allows the shared bridge to work
out of the box for all tenants.
"""
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
config = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
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
elif enabled_raw == 'false':
enabled = False
else:
# No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url)
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,
'phone_number': config.get('whatsapp_phone_number', ''),
}
def _get_branch_phone(tenant_conn, branch_id=None):
"""Obtener teléfono de la sucursal."""
if not tenant_conn:
return '(pendiente)'
try:
cur = tenant_conn.cursor()
if branch_id:
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if row and row[0]:
cur.close()
return row[0]
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else '(pendiente)'
except Exception as e:
print(f"[WA-SM] get_branch_phone error: {e}")
return '(pendiente)'
def _resolve_mye_ids(vehicle, master_conn):
"""Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle:
return []
brand = vehicle.get('brand', '').strip()
model = vehicle.get('model', '').strip()
year = str(vehicle.get('year', '')).strip()
if not brand and not model:
return []
cur = master_conn.cursor()
clauses = []
params = []
if brand:
clauses.append("b.name_brand ILIKE %s")
params.append(f'%{brand}%')
if model:
clauses.append("m.name_model ILIKE %s")
params.append(f'%{model}%')
if year and year.isdigit():
clauses.append("y.year_car = %s")
params.append(int(year))
if not clauses:
cur.close()
return []
cur.execute(f"""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE {' AND '.join(clauses)}
LIMIT 50
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [r[0] for r in rows]
def _get_conversation_history(phone, tenant_conn, limit=4):
"""Fetch recent messages for *phone* to give the AI conversation context.
Includes both user and assistant messages, truncated to keep token count low.
The most recent message (the one currently being processed) is excluded.
"""
if not tenant_conn or not phone:
return []
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT direction, message_text
FROM whatsapp_messages
WHERE phone = %s
ORDER BY created_at DESC
LIMIT %s OFFSET 1
""", (phone, limit))
rows = cur.fetchall()
cur.close()
# Reverse so oldest-first (chronological) for the LLM
history = []
for direction, text in reversed(rows):
if not text:
continue
role = "assistant" if direction == "outgoing" else "user"
# Truncate assistant replies more aggressively (they contain JSON/tables)
max_len = 200 if role == "assistant" else 300
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
history.append({"role": role, "content": truncated})
return history
except Exception as e:
print(f"[WA-AI] Failed to load conversation history: {e}")
return []
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
If *vehicle* is provided and we have a master_conn, we first look up the
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
only show parts that are known to fit the user's car.
Returns:
(formatted_text, first_part_dict) — first_part_dict is used by the
quotation system to know what to add when the user says "cotizar".
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
"""
if not tenant_conn:
return None, None
try:
from services.translations import PART_TRANSLATIONS
# Split search_query by '|' into individual terms
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
if not raw_terms:
raw_terms = [search_query] if search_query else []
# Translate each term to Spanish if possible
search_terms = set()
for term in raw_terms:
search_terms.add(term)
# Check if any English translation matches
for en, es in PART_TRANSLATIONS.items():
if en.upper() == term.upper():
search_terms.add(es)
break
# Also check if the term contains an English word
if en.upper() in term.upper():
search_terms.add(term.upper().replace(en.upper(), es))
search_terms = list(search_terms)
if not search_terms:
return None, None
# Vehicle-aware filtering
mye_ids = _resolve_mye_ids(vehicle, master_conn)
def _do_search(use_compat=True):
"""Run inventory search. Returns list of rows."""
conditions = []
params = []
for term in search_terms:
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
like = f'%{term}%'
params.extend([like, like, like])
where_search = ' OR '.join(conditions)
compat_clause = ""
if use_compat and mye_ids:
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
params.extend(mye_ids)
cur = tenant_conn.cursor()
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
COALESCE(s.stock, 0) AS stock,
i.unit, i.location
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
{compat_clause}
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall()
cur.close()
return rows
# 1. Try with vehicle compatibility filter
rows = _do_search(use_compat=True)
compat_filter_applied = bool(mye_ids)
# 2. If no results with compatibility, try WITHOUT filter
fallback_rows = []
if not rows and mye_ids:
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Nothing found in local inventory — let the AI's original response stand.
# The webhook will append a soft note instead of replacing the message.
return None, None
# Use fallback rows if primary search returned nothing
using_fallback = False
if not rows and fallback_rows:
rows = fallback_rows
using_fallback = True
in_stock = [r for r in rows if r[7] > 0]
out_stock = [r for r in rows if r[7] <= 0]
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
first_part = None
if best:
first_part = {
'inventory_id': best[0],
'part_number': best[1],
'name': best[2],
'brand': best[3] or '',
'price': float(best[4]) if best[4] else 0,
'tax_rate': 0.16,
'stock': best[7],
'unit': best[8] or 'PZA',
}
lines = []
if using_fallback:
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
lines.append("")
if in_stock:
lines.append('✅ *Tenemos en stock:*')
lines.append('')
for r in in_stock:
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
lines.append(f' • {brand_str} {name}')
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
lines.append('')
elif out_stock:
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
lines.append('')
for r in out_stock[:5]:
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else ''
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
lines.append('')
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
# Vehicle context
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
lines.append(f'🚗 Vehículo: {v_str.strip()}')
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
return '\n'.join(lines), first_part
except Exception as e:
print(f"[WA-AI] Enrichment error: {e}")
import traceback
traceback.print_exc()
return None, None
return None, None
@whatsapp_bp.route('/status', methods=['GET'])
@require_auth()
def status():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/qr', methods=['GET'])
@require_auth()
def qr():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/connect', methods=['POST'])
@require_auth()
def connect():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/logout', methods=['POST'])
@require_auth()
def logout():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/webhook', methods=['POST'])
def webhook():
"""Receive messages from Baileys bridge (public, no auth).
Nuevo flujo: máquina de estados estructurada.
"""
data = request.get_json(force=True, silent=True) or {}
if data.get('event') != 'messages.upsert':
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})
phone = msg['phone']
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
text = msg.get('text', '')
media_kind = msg.get('media_kind', 'text')
# Audio transcription (voice notes)
if media_kind == 'audio' and msg.get('media_base64'):
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
if transcript:
text = transcript
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
except ImportError:
pass
except Exception as e:
print(f"[WA-SM] Whisper transcription failed: {e}")
# Location message: if current state expects it, store coordinates
if media_kind == 'location' and msg.get('latitude') is not None:
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
# Image without caption: provide a default text so the state machine can handle it
if media_kind == 'image' and not text:
text = "(imagen)"
# Resolve tenant
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
if not tenant_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()
# Find first tenant with whatsapp_enabled in their config
for tid, db_name in tenants:
try:
from tenant_db import get_tenant_conn_by_dbname
tconn = get_tenant_conn_by_dbname(db_name)
tcur = tconn.cursor()
tcur.execute(
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
)
row = tcur.fetchone()
tcur.close()
tconn.close()
if row and row[0].lower() == 'true':
tenant_id = tid
break
except Exception:
continue
except Exception:
tenant_id = None
tenant_conn = None
master_conn = None
try:
tenant_conn = get_tenant_conn(tenant_id)
master_conn = get_master_conn()
wa_config = _get_whatsapp_config(tenant_conn)
# Deduplicate by wa_message_id
wa_message_id = msg.get('message_id')
if wa_message_id:
cur = tenant_conn.cursor()
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
if cur.fetchone():
cur.close()
return jsonify({'ok': True})
cur.close()
# 1. Log incoming message
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s)
ON CONFLICT DO NOTHING
""", (phone, text, wa_message_id, msg.get('push_name')))
tenant_conn.commit()
cur.close()
# 2. Load session state
from services.wa_state_machine import get_session, save_session, process_message, StateContext
session = get_session(tenant_conn, phone)
# 3. Check session expiry (30 minutes)
current_state = session.get('state', 'idle')
state_data = session.get('state_data', {})
last_updated = session.get('updated_at')
if last_updated and hasattr(last_updated, 'strftime'):
# PostgreSQL returns datetime objects (often timezone-aware)
from datetime import timezone
now = datetime.now(timezone.utc)
if last_updated.tzinfo is None:
now = now.replace(tzinfo=None)
elapsed = (now - last_updated).total_seconds()
if elapsed > 1800:
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
elif last_updated and isinstance(last_updated, str):
from datetime import datetime as dt
try:
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
if elapsed > 1800:
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
except Exception:
pass
# Global reset commands work from any state
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
# Abandoned quotation follow-up
try:
from services.part_kits import should_send_followup
followup = should_send_followup(phone, tenant_conn)
if followup:
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)",
(phone, followup)
)
tenant_conn.commit()
cur_fu.close()
except Exception as fu_err:
print(f"[WA-SM] Follow-up send failed: {fu_err}")
# 4. Build context
context = StateContext(
tenant_conn=tenant_conn,
master_conn=master_conn,
wa_config=wa_config,
tenant_id=tenant_id,
phone=phone,
media_kind=media_kind,
media_base64=msg.get('media_base64'),
push_name=msg.get('push_name'),
)
# 5. Process through state machine
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=current_state,
state_data=state_data,
context=context,
)
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
loop_guard = 0
while reply is None and loop_guard < 5:
loop_guard += 1
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=next_state,
state_data=next_state_data,
context=context,
)
# 6. Save new state
save_session(tenant_conn, phone, next_state, next_state_data)
# 7. Send reply
if reply:
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
# Log outgoing
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (phone, reply))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-SM] Webhook error: {e}")
import traceback
traceback.print_exc()
# Fallback: enviar mensaje de error genérico
try:
if tenant_conn:
phone_branch = _get_branch_phone(tenant_conn, None)
fallback = (
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
f"Por favor llámanos directamente al {phone_branch}."
)
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
except Exception:
pass
finally:
if tenant_conn:
try:
tenant_conn.close()
except Exception:
pass
if master_conn:
try:
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():
data = request.get_json() or {}
phone = data.get('phone', '')
message = data.get('message', '')
if not phone or not message:
return jsonify({'error': 'phone and message required'}), 400
# Load tenant WhatsApp config
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
if not cfg['enabled'] or not cfg['bridge_url']:
conn.close()
return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400
result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url'])
# Save outgoing message
try:
cur = conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (phone, message))
conn.commit()
cur.close()
except Exception:
pass
finally:
conn.close()
return jsonify(result)
@whatsapp_bp.route('/conversations', methods=['GET'])
@require_auth()
def conversations():
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
# so all variants of the same number are grouped together.
cur.execute("""
WITH cleaned AS (
SELECT
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
message_text,
direction,
created_at,
push_name
FROM whatsapp_messages
)
SELECT clean_phone,
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
MAX(created_at) AS last_at,
COUNT(*) AS msg_count,
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
FROM cleaned
GROUP BY clean_phone
ORDER BY MAX(created_at) DESC
LIMIT 50
""")
convos = [{
'phone': r[0],
'last_message': r[1] or '',
'last_direction': r[2] or 'incoming',
'last_at': str(r[3]),
'count': r[4],
'contact_name': r[5] or '',
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'conversations': convos})
except Exception as e:
return jsonify({'conversations': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
@require_auth()
def conversation_messages(phone):
# Strip @lid or @s.whatsapp.net suffix for DB lookup
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Match all variants of this phone number
cur.execute("""
SELECT id, direction, message_text, created_at
FROM whatsapp_messages
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
ORDER BY created_at
LIMIT 100
""", (clean_phone,))
msgs = [{
'id': r[0],
'direction': r[1],
'message_text': r[2] or '',
'created_at': str(r[3]),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'messages': msgs})
except Exception as e:
return jsonify({'messages': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
@require_auth()
def delete_conversation(phone):
"""Delete all messages for a phone number."""
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
DELETE FROM whatsapp_messages
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
""", (clean_phone,))
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500
@whatsapp_bp.route('/conversations', methods=['DELETE'])
@require_auth()
def delete_all_conversations():
"""Delete ALL whatsapp messages."""
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("DELETE FROM whatsapp_messages")
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500