# /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 _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(): return jsonify(whatsapp_service.get_status()) @whatsapp_bp.route('/qr', methods=['GET']) @require_auth() def qr(): return jsonify(whatsapp_service.get_qr()) @whatsapp_bp.route('/connect', methods=['POST']) @require_auth() def connect(): return jsonify(whatsapp_service.connect()) @whatsapp_bp.route('/logout', methods=['POST']) @require_auth() def logout(): return jsonify(whatsapp_service.logout()) @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}) # Reuse one tenant connection for the whole webhook path — we need it # for persistence AND for the inventory-context lookup. # TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives. tenant_id = 11 tenant_conn = None master_conn = None inventory_context = None try: tenant_conn = get_tenant_conn(tenant_id) master_conn = get_master_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) 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) 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) 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 result = whatsapp_service.send_message(phone, message) # Save outgoing message try: conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s) """, (phone, message)) conn.commit() cur.close() conn.close() except Exception: pass 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