# /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 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 '' 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 { 'bridge_url': bridge_url, 'bridge_key': bridge_key, 'enabled': enabled, '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: # 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). 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 # Prepare phone and reply target early reply_to = msg.get('jid') or msg['phone'] media_kind = msg.get('media_kind', 'text') clean_phone = msg.get('phone', '') 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 # 2c. Urgency detection — if customer signals urgency, add a note try: from services.part_kits import is_urgent, urgency_note if msg.get('text') and is_urgent(msg['text']): if inventory_context: inventory_context += urgency_note() else: inventory_context = urgency_note().strip() except Exception as e: print(f"[WA-AI] urgency detection failed: {e}") # 2d. Purchase history — append recent confirmed orders for this customer try: from services.part_kits import get_purchase_history history = get_purchase_history(clean_phone, tenant_conn) if history: if inventory_context: inventory_context += "\n\n" + history else: inventory_context = history except Exception as e: print(f"[WA-AI] Purchase history failed: {e}") # 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(tenant_conn, 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}") if tenant_conn: try: tenant_conn.rollback() except Exception: pass # 3. Dispatch by media kind + quotation commands reply = None # ── Abandoned quotation follow-up ── # If customer has an active quote and hasn't interacted in 15+ min, # send a gentle nudge before processing their current message. try: from services.part_kits import should_send_followup followup = should_send_followup(clean_phone, tenant_conn) if followup: whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url')) if tenant_conn: cur_fu = tenant_conn.cursor() cur_fu.execute( "INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, followup) ) tenant_conn.commit() cur_fu.close() except Exception as fu_err: print(f"[WA-AI] Follow-up send failed: {fu_err}") # ── Location message → nearest branch ── if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None: from services.geo_branches import find_nearest_branch nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude']) if nearest: reply = ( f"📍 *Sucursal más cercana:*\n\n" f"*{nearest['name']}*\n" f"📌 {nearest['address']}\n" f"📞 {nearest['phone']}\n" f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n" f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?" ) else: reply = ( "📍 Gracias por tu ubicación.\n\n" "Actualmente no tenemos sucursales registradas con coordenadas. " "¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío." ) # ── Check for quotation commands FIRST (before AI) ── if not reply and 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, ) from services.quote_image import generate_quote_image from services.whatsapp_service import send_image 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(tenant_conn, 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._' ) # Smart kit suggestion — cross-sell related parts try: from services.part_kits import build_kit_text kit_text = build_kit_text(last_part.get('name', '')) if kit_text: reply += kit_text except Exception as kit_err: print(f"[WA-AI] Kit suggestion failed: {kit_err}") 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: # Generate rich visual quote image and send it try: quote_items = [] for it in detail.get('items', []): quote_items.append({ 'name': it.get('name', ''), 'sku': it.get('sku', ''), 'qty': it.get('quantity', 1), 'price': float(it.get('unit_price', 0)), 'total': float(it.get('total', 0)), }) totals = { 'subtotal': float(detail.get('subtotal', 0)), 'tax': float(detail.get('tax', 0)), 'total': float(detail.get('total', 0)), } tenant_name = tenant_config.get('business_name', 'Autopartes') b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name) img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url) if img_result.get('success'): reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply else: print(f"[WA-AI] Image send failed: {img_result}") except Exception as img_err: print(f"[WA-AI] Quote image generation failed: {img_err}") 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=4) 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'): txt = msg['text'].strip().lower() # Quick welcome menu for new customers with no vehicle is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos') if is_greeting: try: from services.wa_quotation import get_vehicle veh = get_vehicle(tenant_conn, clean_phone) if not veh: reply = ( "¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n" "Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n" "1️⃣ *Marca, modelo y año* de tu vehículo\n" "2️⃣ La *parte* que necesitas\n" "3️⃣ O escribe *menú* para ver opciones\n\n" '_Ejemplo: "Necesito balatas para Tsuru 2015"_' ) except Exception: pass if not reply: # 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(tenant_conn, 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 elif not found_part and vehicle and vehicle.get('brand'): # Only say "not in stock" when we have a specific vehicle # and still found nothing. Otherwise let the AI ask for vehicle info. reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_" # 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(tenant_conn, 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/', 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/', 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