- 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
961 lines
36 KiB
Python
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
|