feat(pos): replace Meta Cloud API WhatsApp with Evolution API (self-hosted)

Switch from Meta Business Cloud API to Evolution API for WhatsApp integration.
Evolution API is self-hosted, free, and connects via WhatsApp Web QR code scan.

- Add docker-compose for Evolution API deployment
- Rewrite whatsapp_service.py for Evolution API endpoints
- Add instance management (create, QR, status, logout) to blueprint
- Add QR code scanning UI with connection status indicator
- Add duplicate message prevention in webhook handler
- Update config.py with EVOLUTION_API_URL/KEY (remove old Meta vars)
- Add setup documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 03:15:52 +00:00
parent 04340f2f29
commit 5f92fe83ba
7 changed files with 672 additions and 293 deletions

View File

@@ -1,20 +1,22 @@
# /home/Autopartes/pos/blueprints/whatsapp_bp.py
"""WhatsApp Business API blueprint.
"""WhatsApp via Evolution API blueprint.
Endpoints:
GET /pos/api/whatsapp/webhook — Meta webhook verification (public)
POST /pos/api/whatsapp/webhook — Receive incoming messages (public)
POST /pos/api/whatsapp/send — Send message to phone (auth)
POST /pos/api/whatsapp/send-quote/<id> — Send quotation via WhatsApp (auth)
GET /pos/api/whatsapp/conversations — List recent conversations (auth)
GET /pos/api/whatsapp/conversations/<phone> — Conversation history (auth)
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/<id> -- Send quotation via WhatsApp (auth)
GET /pos/api/whatsapp/conversations -- List recent conversations (auth)
GET /pos/api/whatsapp/conversations/<phone> -- 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 config import WHATSAPP_VERIFY_TOKEN
from services import whatsapp_service
logger = logging.getLogger(__name__)
@@ -22,78 +24,171 @@ logger = logging.getLogger(__name__)
whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp")
# ── Webhook (PUBLIC — no auth, Meta must be able to call these) ──────────
def _get_instance_name():
"""Derive Evolution instance name from tenant info."""
return getattr(g, 'tenant_db_name', None) or f'tenant_{g.tenant_id}'
@whatsapp_bp.route("/webhook", methods=["GET"])
def webhook_verify():
"""Meta webhook verification challenge.
Meta sends: hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>
We must return the challenge value if the verify_token matches.
"""
mode = request.args.get("hub.mode", "")
token = request.args.get("hub.verify_token", "")
challenge = request.args.get("hub.challenge", "")
# -- Instance management (authenticated) -------------------------------------
if mode == "subscribe" and token == WHATSAPP_VERIFY_TOKEN:
logger.info("WhatsApp webhook verified")
return challenge, 200
else:
logger.warning("WhatsApp webhook verification failed — token mismatch")
return "Forbidden", 403
@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 Meta.
"""Receive incoming WhatsApp messages from Evolution API.
Stores in DB (using tenant_id=1 as default for webhook context)
and triggers auto-response via AI chatbot if available.
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"):
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("wa_message_id", ""),
status="received",
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,
)
# Store auto-reply if one was generated
if result.get("auto_reply"):
# 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=result.get("auto_reply_wa_id", ""),
status="sent",
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,
)
# Always return 200 to Meta (otherwise they retry)
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_<id> 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:
# Webhook context — try tenant 1 as default
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,
@@ -112,7 +207,7 @@ def _store_message(phone, direction, message_text, message_type="text",
return None
# ── Authenticated endpoints ──────────────────────────────────────────────
# -- Authenticated endpoints --------------------------------------------------
@whatsapp_bp.route("/send", methods=["POST"])
@require_auth()
@@ -125,14 +220,15 @@ def send_message():
if not phone or not text:
return jsonify({"error": "phone and message required"}), 400
result = whatsapp_service.send_message(phone, text)
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("wa_message_id", ""),
wa_message_id=result.get('key', {}).get('id', ''),
status="sent",
tenant_id=g.tenant_id,
)
@@ -152,11 +248,11 @@ def send_quote(quote_id):
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:
# Load quotation header
cur.execute("""
SELECT id, folio, customer_id, subtotal, tax, total, status, notes
FROM sales
@@ -166,7 +262,6 @@ def send_quote(quote_id):
if not quote:
return jsonify({"error": "Quotation not found"}), 404
# Load quotation items
cur.execute("""
SELECT si.quantity, si.unit_price, si.discount_pct, si.subtotal,
i.name, i.part_number
@@ -182,7 +277,6 @@ def send_quote(quote_id):
"name": f"{r[4]} ({r[5]})" if r[5] else r[4],
})
# Load tenant business name
from tenant_db import get_master_conn
master = get_master_conn()
mcur = master.cursor()
@@ -203,15 +297,14 @@ def send_quote(quote_id):
"notes": quote[7],
}
result = whatsapp_service.send_quote(phone, quote_data)
result = whatsapp_service.send_quote(instance_name, phone, quote_data)
if "error" not in result:
# Build the same formatted text the service would have sent
_store_message(
phone=phone,
direction="outgoing",
message_text=f"[Cotizacion #{quote_data['quote_number']}] Total: ${quote_data['total']:,.2f}",
wa_message_id=result.get("wa_message_id", ""),
wa_message_id=result.get('key', {}).get('id', ''),
status="sent",
related_type="quotation",
related_id=quote_id,
@@ -252,7 +345,6 @@ def list_conversations():
"last_at": r[5].isoformat() if r[5] else None,
})
# Sort by most recent first
conversations.sort(key=lambda c: c["last_at"] or "", reverse=True)
return jsonify({"conversations": conversations})
@@ -297,7 +389,6 @@ def conversation_history(phone):
"created_at": r[9].isoformat() if r[9] else None,
})
# Also get total count
cur.execute("SELECT COUNT(*) FROM whatsapp_messages WHERE phone = %s", (phone,))
total = cur.fetchone()[0]