- Refactor whatsapp_service.py to accept bridge_url parameter - whatsapp_bp.py: remove hardcoded tenant_id=11, use g.tenant_id - whatsapp_bp.py: webhook now accepts ?tenant_id param with fallback - config_bp.py: add GET/PUT /config/whatsapp endpoints - Each tenant can now have its own Baileys bridge URL and settings
783 lines
32 KiB
Python
783 lines
32 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
|
||
|
||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||
|
||
|
||
def _get_whatsapp_config(conn):
|
||
"""Read WhatsApp bridge configuration from tenant_config.
|
||
Returns dict with bridge_url, enabled, etc."""
|
||
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()
|
||
return {
|
||
'bridge_url': config.get('whatsapp_bridge_url', ''),
|
||
'bridge_key': config.get('whatsapp_bridge_key', ''),
|
||
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
|
||
'phone_number': config.get('whatsapp_phone_number', ''),
|
||
}
|
||
|
||
|
||
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:
|
||
# Truly nothing found — return a conversational message that doesn't kill the chat
|
||
v_str = ""
|
||
if vehicle and vehicle.get('brand'):
|
||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
|
||
|
||
msg_parts = [
|
||
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
|
||
]
|
||
if v_str:
|
||
msg_parts.append(f"Para tu {v_str}, puedo:")
|
||
else:
|
||
msg_parts.append("Te puedo ayudar de estas formas:")
|
||
msg_parts.extend([
|
||
"",
|
||
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
|
||
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
|
||
"• *Sugerir refaccionarias cercanas* — si es urgente",
|
||
"",
|
||
"¿Qué prefieres? O dime si quieres buscar otra parte."
|
||
])
|
||
return '\n'.join(msg_parts), 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).
|
||
|
||
Flow:
|
||
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
||
2. Build inventory context for the AI (what this tenant has in stock).
|
||
3. Ask the chatbot for a reply, enriched with that context.
|
||
4. Send the reply back via the Baileys bridge.
|
||
"""
|
||
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)
|
||
if not msg.get('phone') or msg.get('from_me'):
|
||
return jsonify({'ok': True})
|
||
|
||
# Resolve tenant: try query param first, then fallback to first enabled tenant
|
||
tenant_id = request.args.get('tenant_id', type=int)
|
||
if not tenant_id:
|
||
# Fallback: find first tenant with whatsapp enabled
|
||
try:
|
||
mconn = get_master_conn()
|
||
mcur = mconn.cursor()
|
||
mcur.execute("""
|
||
SELECT t.id FROM tenants t
|
||
JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true'
|
||
WHERE t.is_active = true
|
||
ORDER BY t.id LIMIT 1
|
||
""")
|
||
row = mcur.fetchone()
|
||
mcur.close()
|
||
mconn.close()
|
||
tenant_id = row[0] if row else None
|
||
except Exception:
|
||
tenant_id = None
|
||
|
||
tenant_conn = None
|
||
master_conn = None
|
||
inventory_context = None
|
||
wa_config = {}
|
||
try:
|
||
tenant_conn = get_tenant_conn(tenant_id)
|
||
master_conn = get_master_conn()
|
||
wa_config = _get_whatsapp_config(tenant_conn)
|
||
|
||
# 1. Log the incoming message (with contact display name)
|
||
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
|
||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
|
||
# 2. Build inventory context once per webhook call so the chatbot
|
||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
||
try:
|
||
from services.ai_chat import get_inventory_context
|
||
inventory_context = get_inventory_context(tenant_conn)
|
||
except Exception as e:
|
||
print(f"[WA-AI] inventory_context failed: {e}")
|
||
inventory_context = None
|
||
|
||
# 2b. Append previously-detected vehicle so the AI keeps context
|
||
# even when we don't send full conversation history (Hermes is slow with it)
|
||
try:
|
||
from services.wa_quotation import get_vehicle
|
||
saved_vehicle = get_vehicle(clean_phone)
|
||
if saved_vehicle and inventory_context:
|
||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
||
if v_str:
|
||
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
|
||
elif saved_vehicle:
|
||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
||
if v_str:
|
||
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
|
||
except Exception as e:
|
||
print(f"[WA-AI] vehicle_context failed: {e}")
|
||
except Exception as e:
|
||
print(f"[WA-AI] tenant connection failed: {e}")
|
||
|
||
# 3. Dispatch by media kind + quotation commands
|
||
reply = None
|
||
reply_to = msg.get('jid') or msg['phone']
|
||
media_kind = msg.get('media_kind', 'text')
|
||
clean_phone = msg.get('phone', '')
|
||
|
||
# ── Check for quotation commands FIRST (before AI) ──
|
||
if media_kind == 'text' and msg.get('text'):
|
||
from services.wa_quotation import (
|
||
detect_quote_intent, get_open_quotation, create_quotation,
|
||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||
)
|
||
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
||
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
||
|
||
if intent == 'add':
|
||
last_part = get_last_shown_part(clean_phone)
|
||
if not last_part:
|
||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||
elif tenant_conn:
|
||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||
if not qid:
|
||
qid = create_quotation(tenant_conn, clean_phone)
|
||
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
||
detail = get_quotation_detail(tenant_conn, qid)
|
||
item_count = len(detail['items']) if detail else 0
|
||
reply = (
|
||
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
||
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
||
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
||
)
|
||
|
||
elif intent == 'send':
|
||
if tenant_conn:
|
||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||
if qid:
|
||
detail = get_quotation_detail(tenant_conn, qid)
|
||
reply = format_quotation_wa(detail)
|
||
if not reply:
|
||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||
else:
|
||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||
|
||
elif intent == 'clear':
|
||
if tenant_conn:
|
||
clear_quotation(tenant_conn, clean_phone)
|
||
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
||
|
||
elif intent == 'confirm':
|
||
if tenant_conn:
|
||
qid = confirm_quotation(tenant_conn, clean_phone)
|
||
if qid:
|
||
reply = (
|
||
f'✅ *Pedido confirmado!*\n\n'
|
||
f'Tu cotización #{qid} fue registrada.\n'
|
||
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
||
f'¡Gracias por tu compra! 🙏'
|
||
)
|
||
else:
|
||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||
|
||
# ── Check for conversation reset commands ──
|
||
if media_kind == 'text' and msg.get('text'):
|
||
txt_lower = msg['text'].lower().strip()
|
||
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
|
||
if tenant_conn:
|
||
try:
|
||
cur_del = tenant_conn.cursor()
|
||
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
|
||
tenant_conn.commit()
|
||
cur_del.close()
|
||
except Exception as del_err:
|
||
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
|
||
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
|
||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||
if tenant_conn:
|
||
try:
|
||
cur_save = tenant_conn.cursor()
|
||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||
tenant_conn.commit()
|
||
cur_save.close()
|
||
except Exception:
|
||
pass
|
||
if tenant_conn:
|
||
try: tenant_conn.close()
|
||
except Exception: pass
|
||
return jsonify({'ok': True})
|
||
|
||
if intent is not None:
|
||
# It was a quote command — send reply and skip the AI
|
||
if reply:
|
||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||
if tenant_conn:
|
||
try:
|
||
cur_save = tenant_conn.cursor()
|
||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||
tenant_conn.commit()
|
||
cur_save.close()
|
||
except Exception:
|
||
pass
|
||
# Clean up and return early
|
||
if tenant_conn:
|
||
try: tenant_conn.close()
|
||
except Exception: pass
|
||
return jsonify({'ok': True})
|
||
|
||
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
|
||
conversation_history = []
|
||
if tenant_conn:
|
||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
|
||
if conversation_history:
|
||
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
|
||
|
||
try:
|
||
if media_kind == 'image' and msg.get('media_base64'):
|
||
from services.ai_chat import chat_with_image
|
||
# Prompt: use the caption if provided, else default to
|
||
# "identify this part" which chat_with_image handles gracefully.
|
||
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
||
ai_resp = chat_with_image(
|
||
user_message=prompt,
|
||
image_base64=msg['media_base64'],
|
||
conversation_history=conversation_history,
|
||
inventory_context=inventory_context,
|
||
)
|
||
reply = ai_resp.get('message', '') or ''
|
||
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
||
|
||
elif media_kind == 'audio' and msg.get('media_base64'):
|
||
# Voice note handling — transcribe first, then chat().
|
||
# See services.whisper_local for the transcriber.
|
||
try:
|
||
from services.whisper_local import transcribe_audio_base64
|
||
transcript = transcribe_audio_base64(
|
||
msg['media_base64'],
|
||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||
)
|
||
except ImportError:
|
||
transcript = None
|
||
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
||
except Exception as e:
|
||
transcript = None
|
||
print(f"[WA-AI] Whisper transcription failed: {e}")
|
||
|
||
if transcript:
|
||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||
from services.ai_chat import chat
|
||
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
|
||
reply = ai_resp.get('message', '') or ''
|
||
# Prefix the reply so the sender knows we understood the voice note
|
||
if reply:
|
||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||
else:
|
||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||
'Puedes escribirme el mensaje?')
|
||
|
||
elif msg.get('text'):
|
||
# Plain text message — standard chatbot flow
|
||
from services.ai_chat import chat
|
||
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
|
||
reply = ai_resp.get('message', '') or ''
|
||
|
||
# Enrich: if the AI returned a search_query, look up real parts
|
||
# from the catalog and append them to the WhatsApp reply.
|
||
search_q = ai_resp.get('search_query')
|
||
vehicle = ai_resp.get('vehicle')
|
||
|
||
# Persist detected vehicle so we don't lose context between messages
|
||
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
|
||
try:
|
||
from services.wa_quotation import set_vehicle
|
||
set_vehicle(clean_phone, vehicle)
|
||
except Exception as veh_err:
|
||
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
|
||
|
||
if search_q and reply:
|
||
try:
|
||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
|
||
if enrichment:
|
||
reply = reply + '\n\n' + enrichment
|
||
# Track the found part so "cotizar" can add it
|
||
if found_part:
|
||
from services.wa_quotation import set_last_shown_part
|
||
set_last_shown_part(clean_phone, found_part)
|
||
except Exception as enrich_err:
|
||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||
|
||
# Send reply if we produced one
|
||
if reply:
|
||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||
|
||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||
if tenant_conn:
|
||
try:
|
||
cur2 = tenant_conn.cursor()
|
||
cur2.execute("""
|
||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||
VALUES (%s, 'outgoing', %s)
|
||
""", (msg['phone'], reply))
|
||
tenant_conn.commit()
|
||
cur2.close()
|
||
except Exception as db_err:
|
||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||
|
||
except Exception as e:
|
||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||
|
||
# 4. Clean up connections
|
||
if tenant_conn is not None:
|
||
try:
|
||
tenant_conn.close()
|
||
except Exception:
|
||
pass
|
||
if master_conn is not None:
|
||
try:
|
||
master_conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@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
|