# /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 from services import whatsapp_service whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn): """Search the refaccionaria's LOCAL inventory and build a WhatsApp reply. 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: # Translate common English search terms to Spanish for local inventory # (the AI sends search_query in English, but local inventory names # are often in Spanish) from services.translations import PART_TRANSLATIONS search_terms = [search_query] # Add the Spanish translation if we have one for en, es in PART_TRANSLATIONS.items(): if en.upper() in search_query.upper(): search_terms.append(es) break # Build ILIKE conditions for all search terms 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) cur = tenant_conn.cursor() cur.execute(f""" SELECT 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 ( SELECT inventory_id, SUM(quantity) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE i.is_active = TRUE AND ({where_search}) ORDER BY COALESCE(s.stock, 0) > 0 DESC, i.name LIMIT 10 """, params) rows = cur.fetchall() cur.close() if not rows: return ('❌ No tenemos esa parte en inventario actualmente.\n' '_Puedes preguntar por otra parte o visitarnos en tienda._'), None # Split into in-stock and out-of-stock in_stock = [r for r in rows if r[6] > 0] out_stock = [r for r in rows if r[6] <= 0] # Build the first-part dict for quotation tracking # Use the first in-stock part, or first out-of-stock if none available 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': None, # we'd need the id — fetch it 'part_number': best[0], 'name': best[1], 'brand': best[2] or '', 'price': float(best[3]) if best[3] else 0, 'tax_rate': 0.16, 'stock': best[6], 'unit': best[7] or 'PZA', } # Fetch the inventory ID for the quotation item FK try: cur2 = tenant_conn.cursor() cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1", (best[0],)) inv_row = cur2.fetchone() if inv_row: first_part['inventory_id'] = inv_row[0] cur2.close() except Exception: pass lines = [] if in_stock: lines.append('✅ *Tenemos en stock:*') lines.append('') for r in in_stock: 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('') else: lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*') lines.append('') for r in out_stock[:5]: 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}") 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 inventory_context = None try: tenant_conn = get_tenant_conn(tenant_id) # 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 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.' 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}) 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'], 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, 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'], 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') if search_q and reply: try: enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_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 the connection if tenant_conn is not None: try: tenant_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