# /home/Autopartes/pos/blueprints/whatsapp_bp.py """WhatsApp via Evolution API blueprint. Endpoints: POST /pos/api/whatsapp/instance/create -- Create WhatsApp instance (auth) GET /pos/api/whatsapp/instance/qr -- Get QR code for scanning (auth) GET /pos/api/whatsapp/instance/status -- Check connection status (auth) POST /pos/api/whatsapp/instance/logout -- Disconnect instance (auth) POST /pos/api/whatsapp/webhook -- Receive messages from Evolution (public) POST /pos/api/whatsapp/send -- Send message (auth) POST /pos/api/whatsapp/send-quote/ -- Send quotation via WhatsApp (auth) GET /pos/api/whatsapp/conversations -- List recent conversations (auth) GET /pos/api/whatsapp/conversations/ -- Conversation history (auth) """ import logging from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services import whatsapp_service logger = logging.getLogger(__name__) whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp") def _get_instance_name(): """Derive Evolution instance name from tenant info.""" return getattr(g, 'tenant_db_name', None) or f'tenant_{g.tenant_id}' # -- Instance management (authenticated) ------------------------------------- @whatsapp_bp.route("/instance/create", methods=["POST"]) @require_auth() def instance_create(): """Create a new WhatsApp instance for this tenant.""" instance_name = _get_instance_name() result = whatsapp_service.create_instance(instance_name) return jsonify(result) @whatsapp_bp.route("/instance/qr", methods=["GET"]) @require_auth() def instance_qr(): """Get QR code image (base64) for WhatsApp connection.""" instance_name = _get_instance_name() result = whatsapp_service.get_qr_code(instance_name) return jsonify(result) @whatsapp_bp.route("/instance/status", methods=["GET"]) @require_auth() def instance_status(): """Check WhatsApp connection status for this tenant.""" instance_name = _get_instance_name() result = whatsapp_service.get_instance_status(instance_name) return jsonify(result) @whatsapp_bp.route("/instance/logout", methods=["POST"]) @require_auth() def instance_logout(): """Disconnect WhatsApp instance.""" instance_name = _get_instance_name() result = whatsapp_service.logout_instance(instance_name) return jsonify(result) # -- Webhook (PUBLIC -- no auth, Evolution API must be able to call this) ----- @whatsapp_bp.route("/webhook", methods=["POST"]) def webhook_receive(): """Receive incoming WhatsApp messages from Evolution API. Evolution sends webhooks for all message events. We store incoming messages in DB and trigger auto-response if available. """ payload = request.get_json(silent=True) if not payload: return "OK", 200 # Evolution sends the event type in the payload event = payload.get('event', '') # We only care about incoming messages if event not in ('messages.upsert', ''): return "OK", 200 result = whatsapp_service.process_incoming(payload) # Determine tenant from instance name in webhook instance_name = payload.get('instance', '') tenant_id = _resolve_tenant_from_instance(instance_name) # Store incoming message in DB if it was a real message if result.get('type') == 'message' and result.get('phone'): _store_message( phone=result['phone'], direction='incoming', message_text=result.get('text', ''), message_type=result.get('message_type', 'text'), wa_message_id=result.get('message_id', ''), status='received', tenant_id=tenant_id, ) # If AI generated an auto-reply, send it and store it if result.get('auto_reply'): send_result = whatsapp_service.send_message( instance_name, result['phone'], result['auto_reply'] ) _store_message( phone=result['phone'], direction='outgoing', message_text=result['auto_reply'], message_type='text', wa_message_id=send_result.get('key', {}).get('id', ''), status='sent', tenant_id=tenant_id, ) elif result.get('type') == 'outgoing' and result.get('phone'): # Our own outgoing messages echoed back by Evolution _store_message( phone=result['phone'], direction='outgoing', message_text=result.get('text', ''), message_type=result.get('message_type', 'text'), wa_message_id=result.get('message_id', ''), status='sent', tenant_id=tenant_id, ) return "OK", 200 def _resolve_tenant_from_instance(instance_name): """Resolve tenant_id from Evolution instance name. Instance names follow the pattern: tenant_ or the db_name itself. """ if not instance_name: return 1 # default fallback if instance_name.startswith('tenant_'): try: return int(instance_name.split('_', 1)[1]) except (ValueError, IndexError): pass # Try to look up by db_name in master try: from tenant_db import get_master_conn conn = get_master_conn() cur = conn.cursor() cur.execute("SELECT id FROM tenants WHERE db_name = %s", (instance_name,)) row = cur.fetchone() cur.close() conn.close() if row: return row[0] except Exception: logger.debug("Could not resolve tenant from instance: %s", instance_name) return 1 # default fallback def _store_message(phone, direction, message_text, message_type="text", wa_message_id="", status="sent", related_type=None, related_id=None, tenant_id=None): """Store a WhatsApp message in the tenant DB.""" tid = tenant_id or getattr(g, "tenant_id", None) if not tid: tid = 1 try: conn = get_tenant_conn(tid) cur = conn.cursor() # Avoid duplicate messages (Evolution may send the same message ID twice) if wa_message_id: cur.execute( "SELECT id FROM whatsapp_messages WHERE wa_message_id = %s", (wa_message_id,) ) if cur.fetchone(): cur.close() conn.close() return None cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text, message_type, wa_message_id, status, related_type, related_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (phone, direction, message_text, message_type, wa_message_id, status, related_type, related_id)) msg_id = cur.fetchone()[0] conn.commit() cur.close() conn.close() return msg_id except Exception: logger.exception("Failed to store WhatsApp message") return None # -- Authenticated endpoints -------------------------------------------------- @whatsapp_bp.route("/send", methods=["POST"]) @require_auth() def send_message(): """Send a text message to a phone number.""" body = request.get_json(force=True) phone = (body.get("phone") or "").strip() text = (body.get("message") or "").strip() if not phone or not text: return jsonify({"error": "phone and message required"}), 400 instance_name = _get_instance_name() result = whatsapp_service.send_message(instance_name, phone, text) if "error" not in result: _store_message( phone=phone, direction="outgoing", message_text=text, wa_message_id=result.get('key', {}).get('id', ''), status="sent", tenant_id=g.tenant_id, ) return jsonify(result) @whatsapp_bp.route("/send-quote/", methods=["POST"]) @require_auth() def send_quote(quote_id): """Send a quotation via WhatsApp. Expects JSON body with 'phone'. Looks up the quote from the DB. """ body = request.get_json(force=True) phone = (body.get("phone") or "").strip() if not phone: return jsonify({"error": "phone required"}), 400 instance_name = _get_instance_name() conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" SELECT id, folio, customer_id, subtotal, tax, total, status, notes FROM sales WHERE id = %s AND sale_type = 'quotation' """, (quote_id,)) quote = cur.fetchone() if not quote: return jsonify({"error": "Quotation not found"}), 404 cur.execute(""" SELECT si.quantity, si.unit_price, si.discount_pct, si.subtotal, i.name, i.part_number FROM sale_items si JOIN inventory i ON i.id = si.inventory_id WHERE si.sale_id = %s """, (quote_id,)) items = [] for r in cur.fetchall(): items.append({ "quantity": r[0], "unit_price": float(r[1]) if r[1] else 0, "name": f"{r[4]} ({r[5]})" if r[5] else r[4], }) from tenant_db import get_master_conn master = get_master_conn() mcur = master.cursor() mcur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,)) trow = mcur.fetchone() biz_name = trow[0] if trow else "Nexus Autoparts" mcur.close() master.close() quote_data = { "business_name": biz_name, "quote_number": quote[1] or str(quote[0]), "items": items, "subtotal": float(quote[3]) if quote[3] else 0, "tax": float(quote[4]) if quote[4] else 0, "total": float(quote[5]) if quote[5] else 0, "validity_days": 7, "notes": quote[7], } result = whatsapp_service.send_quote(instance_name, phone, quote_data) if "error" not in result: _store_message( phone=phone, direction="outgoing", message_text=f"[Cotizacion #{quote_data['quote_number']}] Total: ${quote_data['total']:,.2f}", wa_message_id=result.get('key', {}).get('id', ''), status="sent", related_type="quotation", related_id=quote_id, tenant_id=g.tenant_id, ) return jsonify(result) finally: cur.close() conn.close() @whatsapp_bp.route("/conversations", methods=["GET"]) @require_auth() def list_conversations(): """List recent conversations (grouped by phone number, latest message first).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" SELECT DISTINCT ON (phone) phone, direction, message_text, message_type, status, created_at FROM whatsapp_messages ORDER BY phone, created_at DESC """) rows = cur.fetchall() conversations = [] for r in rows: conversations.append({ "phone": r[0], "last_direction": r[1], "last_message": (r[2] or "")[:120], "last_type": r[3], "last_status": r[4], "last_at": r[5].isoformat() if r[5] else None, }) conversations.sort(key=lambda c: c["last_at"] or "", reverse=True) return jsonify({"conversations": conversations}) finally: cur.close() conn.close() @whatsapp_bp.route("/conversations/", methods=["GET"]) @require_auth() def conversation_history(phone): """Get full message history with a specific phone number.""" limit = min(int(request.args.get("limit", 100)), 500) offset = int(request.args.get("offset", 0)) conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" SELECT id, phone, direction, message_text, message_type, wa_message_id, status, related_type, related_id, created_at FROM whatsapp_messages WHERE phone = %s ORDER BY created_at ASC LIMIT %s OFFSET %s """, (phone, limit, offset)) messages = [] for r in cur.fetchall(): messages.append({ "id": r[0], "phone": r[1], "direction": r[2], "message_text": r[3], "message_type": r[4], "wa_message_id": r[5], "status": r[6], "related_type": r[7], "related_id": r[8], "created_at": r[9].isoformat() if r[9] else None, }) cur.execute("SELECT COUNT(*) FROM whatsapp_messages WHERE phone = %s", (phone,)) total = cur.fetchone()[0] return jsonify({"messages": messages, "total": total}) finally: cur.close() conn.close()