# /home/Autopartes/pos/blueprints/whatsapp_bp.py """WhatsApp via Baileys Bridge. Endpoints: GET /pos/api/whatsapp/status -- Connection status GET /pos/api/whatsapp/qr -- Get QR code POST /pos/api/whatsapp/connect -- Start connection POST /pos/api/whatsapp/logout -- Disconnect POST /pos/api/whatsapp/webhook -- Receive messages (public) POST /pos/api/whatsapp/send -- Send message GET /pos/api/whatsapp/conversations -- List conversations """ from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn, get_master_conn from services import whatsapp_service from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY from datetime import datetime whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') def _get_whatsapp_config(conn): """Read WhatsApp bridge configuration from tenant_config. Falls back to global server config (config.py / env vars) when tenant has no explicit WhatsApp settings. This allows the shared bridge to work out of the box for all tenants. """ cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'") config = {row[0]: row[1] for row in cur.fetchall()} cur.close() bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or '' bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or '' 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 _get_branch_phone(tenant_conn, branch_id=None): """Obtener teléfono de la sucursal.""" if not tenant_conn: return '(pendiente)' try: cur = tenant_conn.cursor() if branch_id: cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,)) row = cur.fetchone() if row and row[0]: cur.close() return row[0] cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'") row = cur.fetchone() cur.close() return row[0] if row and row[0] else '(pendiente)' except Exception as e: print(f"[WA-SM] get_branch_phone error: {e}") return '(pendiente)' def _resolve_mye_ids(vehicle, master_conn): """Return list of MYE ids matching vehicle brand/model/year text.""" if not master_conn or not vehicle: return [] brand = vehicle.get('brand', '').strip() model = vehicle.get('model', '').strip() year = str(vehicle.get('year', '')).strip() if not brand and not model: return [] cur = master_conn.cursor() clauses = [] params = [] if brand: clauses.append("b.name_brand ILIKE %s") params.append(f'%{brand}%') if model: clauses.append("m.name_model ILIKE %s") params.append(f'%{model}%') if year and year.isdigit(): clauses.append("y.year_car = %s") params.append(int(year)) if not clauses: cur.close() return [] cur.execute(f""" SELECT mye.id_mye FROM model_year_engine mye JOIN models m ON m.id_model = mye.model_id JOIN brands b ON b.id_brand = m.brand_id JOIN years y ON y.id_year = mye.year_id WHERE {' AND '.join(clauses)} LIMIT 50 """, tuple(params)) rows = cur.fetchall() cur.close() return [r[0] for r in rows] def _get_conversation_history(phone, tenant_conn, limit=4): """Fetch recent messages for *phone* to give the AI conversation context. Includes both user and assistant messages, truncated to keep token count low. The most recent message (the one currently being processed) is excluded. """ if not tenant_conn or not phone: return [] try: cur = tenant_conn.cursor() cur.execute(""" SELECT direction, message_text FROM whatsapp_messages WHERE phone = %s ORDER BY created_at DESC LIMIT %s OFFSET 1 """, (phone, limit)) rows = cur.fetchall() cur.close() # Reverse so oldest-first (chronological) for the LLM history = [] for direction, text in reversed(rows): if not text: continue role = "assistant" if direction == "outgoing" else "user" # Truncate assistant replies more aggressively (they contain JSON/tables) max_len = 200 if role == "assistant" else 300 truncated = text[:max_len] + ('...' if len(text) > max_len else '') history.append({"role": role, "content": truncated}) return history except Exception as e: print(f"[WA-AI] Failed to load conversation history: {e}") return [] def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None): """Search the refaccionaria's LOCAL inventory and build a WhatsApp reply. If *vehicle* is provided and we have a master_conn, we first look up the MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we only show parts that are known to fit the user's car. Returns: (formatted_text, first_part_dict) — first_part_dict is used by the quotation system to know what to add when the user says "cotizar". first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate """ if not tenant_conn: return None, None try: from services.translations import PART_TRANSLATIONS # Split search_query by '|' into individual terms raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()] if not raw_terms: raw_terms = [search_query] if search_query else [] # Translate each term to Spanish if possible search_terms = set() for term in raw_terms: search_terms.add(term) # Check if any English translation matches for en, es in PART_TRANSLATIONS.items(): if en.upper() == term.upper(): search_terms.add(es) break # Also check if the term contains an English word if en.upper() in term.upper(): search_terms.add(term.upper().replace(en.upper(), es)) search_terms = list(search_terms) if not search_terms: return None, None # Vehicle-aware filtering mye_ids = _resolve_mye_ids(vehicle, master_conn) def _do_search(use_compat=True): """Run inventory search. Returns list of rows.""" conditions = [] params = [] for term in search_terms: conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)") like = f'%{term}%' params.extend([like, like, like]) where_search = ' OR '.join(conditions) compat_clause = "" if use_compat and mye_ids: compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))" params.extend(mye_ids) cur = tenant_conn.cursor() cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3, COALESCE(s.stock, 0) AS stock, i.unit, i.location FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE i.is_active = TRUE AND ({where_search}) {compat_clause} ORDER BY COALESCE(s.stock, 0) > 0 DESC, i.name LIMIT 10 """, params) rows = cur.fetchall() cur.close() return rows # 1. Try with vehicle compatibility filter rows = _do_search(use_compat=True) compat_filter_applied = bool(mye_ids) # 2. If no results with compatibility, try WITHOUT filter fallback_rows = [] if not rows and mye_ids: fallback_rows = _do_search(use_compat=False) if not rows and not fallback_rows: # Nothing found in local inventory — let the AI's original response stand. # The webhook will append a soft note instead of replacing the message. return None, None # Use fallback rows if primary search returned nothing using_fallback = False if not rows and fallback_rows: rows = fallback_rows using_fallback = True in_stock = [r for r in rows if r[7] > 0] out_stock = [r for r in rows if r[7] <= 0] best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None) first_part = None if best: first_part = { 'inventory_id': best[0], 'part_number': best[1], 'name': best[2], 'brand': best[3] or '', 'price': float(best[4]) if best[4] else 0, 'tax_rate': 0.16, 'stock': best[7], 'unit': best[8] or 'PZA', } lines = [] if using_fallback: lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*") lines.append("") if in_stock: lines.append('✅ *Tenemos en stock:*') lines.append('') for r in in_stock: inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r brand_str = f'*{brand}*' if brand else '' price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio' lines.append(f' • {brand_str} {name}') lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)') lines.append('') elif out_stock: lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*') lines.append('') for r in out_stock[:5]: inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r brand_str = f'*{brand}*' if brand else '' price_str = f'${float(p1):,.2f}' if p1 else '' lines.append(f' • {brand_str} {name} #{part_num} {price_str}') lines.append('') lines.append('_Podemos pedirlo — consulta tiempo de entrega._') # Vehicle context if vehicle and vehicle.get('brand'): v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}" lines.append(f'🚗 Vehículo: {v_str.strip()}') lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._') return '\n'.join(lines), first_part except Exception as e: print(f"[WA-AI] Enrichment error: {e}") import traceback traceback.print_exc() return None, None return None, None @whatsapp_bp.route('/status', methods=['GET']) @require_auth() def status(): conn = get_tenant_conn(g.tenant_id) cfg = _get_whatsapp_config(conn) conn.close() if not cfg['enabled'] or not cfg['bridge_url']: return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'}) return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/qr', methods=['GET']) @require_auth() def qr(): conn = get_tenant_conn(g.tenant_id) cfg = _get_whatsapp_config(conn) conn.close() if not cfg['enabled'] or not cfg['bridge_url']: return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'}) return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/connect', methods=['POST']) @require_auth() def connect(): conn = get_tenant_conn(g.tenant_id) cfg = _get_whatsapp_config(conn) conn.close() if not cfg['enabled'] or not cfg['bridge_url']: return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400 return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/logout', methods=['POST']) @require_auth() def logout(): conn = get_tenant_conn(g.tenant_id) cfg = _get_whatsapp_config(conn) conn.close() if not cfg['enabled'] or not cfg['bridge_url']: return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400 return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/webhook', methods=['POST']) def webhook(): """Receive messages from Baileys bridge (public, no auth). Nuevo flujo: máquina de estados estructurada. """ data = request.get_json(force=True, silent=True) or {} if data.get('event') != 'messages.upsert': return jsonify({'ok': True}) msg = whatsapp_service.process_incoming(data) if not msg.get('phone') or msg.get('from_me'): return jsonify({'ok': True}) phone = msg['phone'] reply_to = msg.get('sender_pn') or msg.get('jid') or phone text = msg.get('text', '') media_kind = msg.get('media_kind', 'text') # Audio transcription (voice notes) if media_kind == 'audio' and msg.get('media_base64'): try: from services.whisper_local import transcribe_audio_base64 transcript = transcribe_audio_base64( msg['media_base64'], mimetype=msg.get('media_mimetype') or 'audio/ogg', ) if transcript: text = transcript print(f"[WA-SM] Voice note transcribed: {transcript[:100]}") except ImportError: pass except Exception as e: print(f"[WA-SM] Whisper transcription failed: {e}") # Location message: if current state expects it, store coordinates if media_kind == 'location' and msg.get('latitude') is not None: text = f"Ubicación: {msg['latitude']},{msg['longitude']}" # Image without caption: provide a default text so the state machine can handle it if media_kind == 'image' and not text: text = "(imagen)" # Resolve tenant tenant_id = request.args.get('tenant_id', type=int) if not tenant_id: try: mconn = get_master_conn() mcur = mconn.cursor() mcur.execute(""" SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id """) tenants = mcur.fetchall() mcur.close() mconn.close() # Find first tenant with whatsapp_enabled in their config for tid, db_name in tenants: try: from tenant_db import get_tenant_conn_by_dbname tconn = get_tenant_conn_by_dbname(db_name) tcur = tconn.cursor() tcur.execute( "SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'" ) row = tcur.fetchone() tcur.close() tconn.close() if row and row[0].lower() == 'true': tenant_id = tid break except Exception: continue except Exception: tenant_id = None tenant_conn = None master_conn = None try: tenant_conn = get_tenant_conn(tenant_id) master_conn = get_master_conn() wa_config = _get_whatsapp_config(tenant_conn) # Deduplicate by wa_message_id wa_message_id = msg.get('message_id') if wa_message_id: cur = tenant_conn.cursor() cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,)) if cur.fetchone(): cur.close() return jsonify({'ok': True}) cur.close() # 1. Log incoming message cur = tenant_conn.cursor() cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name) VALUES (%s, 'incoming', %s, %s, %s) ON CONFLICT DO NOTHING """, (phone, text, wa_message_id, msg.get('push_name'))) tenant_conn.commit() cur.close() # 2. Load session state from services.wa_state_machine import get_session, save_session, process_message, StateContext session = get_session(tenant_conn, phone) # 3. Check session expiry (30 minutes) current_state = session.get('state', 'idle') state_data = session.get('state_data', {}) last_updated = session.get('updated_at') if last_updated and hasattr(last_updated, 'strftime'): # PostgreSQL returns datetime objects (often timezone-aware) from datetime import timezone now = datetime.now(timezone.utc) if last_updated.tzinfo is None: now = now.replace(tzinfo=None) elapsed = (now - last_updated).total_seconds() if elapsed > 1800: current_state = 'idle' state_data = {'customer_id': state_data.get('customer_id')} elif last_updated and isinstance(last_updated, str): from datetime import datetime as dt try: parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00')) elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds() if elapsed > 1800: current_state = 'idle' state_data = {'customer_id': state_data.get('customer_id')} except Exception: pass # Global reset commands work from any state if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'): current_state = 'idle' state_data = {'customer_id': state_data.get('customer_id')} # Abandoned quotation follow-up try: from services.part_kits import should_send_followup followup = should_send_followup(phone, tenant_conn) if followup: whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url')) cur_fu = tenant_conn.cursor() cur_fu.execute( "INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (phone, followup) ) tenant_conn.commit() cur_fu.close() except Exception as fu_err: print(f"[WA-SM] Follow-up send failed: {fu_err}") # 4. Build context context = StateContext( tenant_conn=tenant_conn, master_conn=master_conn, wa_config=wa_config, tenant_id=tenant_id, phone=phone, media_kind=media_kind, media_base64=msg.get('media_base64'), push_name=msg.get('push_name'), ) # 5. Process through state machine reply, next_state, next_state_data = process_message( phone=phone, text=text, current_state=current_state, state_data=state_data, context=context, ) # 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente # (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado) loop_guard = 0 while reply is None and loop_guard < 5: loop_guard += 1 reply, next_state, next_state_data = process_message( phone=phone, text=text, current_state=next_state, state_data=next_state_data, context=context, ) # 6. Save new state save_session(tenant_conn, phone, next_state, next_state_data) # 7. Send reply if reply: result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url')) print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}") # Log outgoing cur = tenant_conn.cursor() cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s) """, (phone, reply)) tenant_conn.commit() cur.close() except Exception as e: print(f"[WA-SM] Webhook error: {e}") import traceback traceback.print_exc() # Fallback: enviar mensaje de error genérico try: if tenant_conn: phone_branch = _get_branch_phone(tenant_conn, None) fallback = ( "Estoy teniendo problemas técnicos en este momento. 😕\n\n" f"Por favor llámanos directamente al {phone_branch}." ) whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url')) except Exception: pass finally: if tenant_conn: try: tenant_conn.close() except Exception: pass if master_conn: try: master_conn.close() except Exception: pass return jsonify({'ok': True}) @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