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>
344 lines
10 KiB
Python
344 lines
10 KiB
Python
"""WhatsApp service via Evolution API (self-hosted, free).
|
|
|
|
Evolution API connects to WhatsApp Web via QR code scan.
|
|
Docs: https://doc.evolution-api.com/
|
|
"""
|
|
|
|
import logging
|
|
import requests
|
|
|
|
from config import EVOLUTION_API_URL, EVOLUTION_API_KEY
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HEADERS = {
|
|
'apikey': EVOLUTION_API_KEY,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
|
|
def is_configured():
|
|
"""Return True if Evolution API credentials are set."""
|
|
return bool(EVOLUTION_API_URL and EVOLUTION_API_KEY)
|
|
|
|
|
|
# -- Instance management -----------------------------------------------------
|
|
|
|
def create_instance(instance_name):
|
|
"""Create a WhatsApp instance (one per tenant/phone number)."""
|
|
try:
|
|
resp = requests.post(
|
|
f'{EVOLUTION_API_URL}/instance/create',
|
|
headers=HEADERS,
|
|
json={
|
|
'instanceName': instance_name,
|
|
'qrcode': True,
|
|
'integration': 'WHATSAPP-BAILEYS'
|
|
},
|
|
timeout=15
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Failed to create Evolution instance")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def get_qr_code(instance_name):
|
|
"""Get QR code to connect WhatsApp.
|
|
|
|
Returns dict with 'base64' key containing data:image/png;base64,... string.
|
|
"""
|
|
try:
|
|
resp = requests.get(
|
|
f'{EVOLUTION_API_URL}/instance/connect/{instance_name}',
|
|
headers=HEADERS,
|
|
timeout=15
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Failed to get QR code")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def get_instance_status(instance_name):
|
|
"""Check if instance is connected.
|
|
|
|
Returns dict with 'state' key: 'open' | 'close' | 'connecting'.
|
|
"""
|
|
try:
|
|
resp = requests.get(
|
|
f'{EVOLUTION_API_URL}/instance/connectionState/{instance_name}',
|
|
headers=HEADERS,
|
|
timeout=10
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Failed to get instance status")
|
|
return {'error': str(e), 'state': 'close'}
|
|
|
|
|
|
def logout_instance(instance_name):
|
|
"""Disconnect WhatsApp instance."""
|
|
try:
|
|
resp = requests.delete(
|
|
f'{EVOLUTION_API_URL}/instance/logout/{instance_name}',
|
|
headers=HEADERS,
|
|
timeout=10
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Failed to logout instance")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def delete_instance(instance_name):
|
|
"""Delete instance completely."""
|
|
try:
|
|
resp = requests.delete(
|
|
f'{EVOLUTION_API_URL}/instance/delete/{instance_name}',
|
|
headers=HEADERS,
|
|
timeout=10
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Failed to delete instance")
|
|
return {'error': str(e)}
|
|
|
|
|
|
# -- Sending messages ---------------------------------------------------------
|
|
|
|
def send_message(instance_name, to_phone, message_text):
|
|
"""Send text message.
|
|
|
|
Args:
|
|
instance_name: Evolution instance name (tenant identifier)
|
|
to_phone: phone in format 5214421234567 (country code + number, no +)
|
|
message_text: text content
|
|
|
|
Returns:
|
|
dict with response or 'error' key on failure.
|
|
"""
|
|
if not is_configured():
|
|
return {'error': 'Evolution API not configured'}
|
|
|
|
try:
|
|
resp = requests.post(
|
|
f'{EVOLUTION_API_URL}/message/sendText/{instance_name}',
|
|
headers=HEADERS,
|
|
json={'number': to_phone, 'text': message_text},
|
|
timeout=15
|
|
)
|
|
data = resp.json()
|
|
if resp.status_code in (200, 201):
|
|
return data
|
|
else:
|
|
err = data.get('message', data.get('error', resp.text))
|
|
logger.error("Evolution send failed: %s", err)
|
|
return {'error': err}
|
|
except Exception as e:
|
|
logger.exception("Evolution send exception")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def send_image(instance_name, to_phone, image_url, caption=''):
|
|
"""Send image message."""
|
|
try:
|
|
resp = requests.post(
|
|
f'{EVOLUTION_API_URL}/message/sendMedia/{instance_name}',
|
|
headers=HEADERS,
|
|
json={
|
|
'number': to_phone,
|
|
'mediatype': 'image',
|
|
'media': image_url,
|
|
'caption': caption
|
|
},
|
|
timeout=30
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Evolution image send exception")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def send_document(instance_name, to_phone, doc_url, filename, caption=''):
|
|
"""Send document (PDF, etc)."""
|
|
try:
|
|
resp = requests.post(
|
|
f'{EVOLUTION_API_URL}/message/sendMedia/{instance_name}',
|
|
headers=HEADERS,
|
|
json={
|
|
'number': to_phone,
|
|
'mediatype': 'document',
|
|
'media': doc_url,
|
|
'fileName': filename,
|
|
'caption': caption
|
|
},
|
|
timeout=30
|
|
)
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.exception("Evolution document send exception")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def send_quote(instance_name, to_phone, quote_data):
|
|
"""Send a formatted quotation to a customer."""
|
|
biz = quote_data.get("business_name", "Nexus Autoparts")
|
|
qnum = quote_data.get("quote_number", "--")
|
|
items = quote_data.get("items", [])
|
|
subtotal = quote_data.get("subtotal", 0)
|
|
tax = quote_data.get("tax", 0)
|
|
total = quote_data.get("total", 0)
|
|
validity = quote_data.get("validity_days", 7)
|
|
|
|
lines = [
|
|
f"*{biz}*",
|
|
f"Cotizacion #{qnum}",
|
|
"---",
|
|
]
|
|
for it in items:
|
|
name = it.get("name", "Articulo")
|
|
qty = it.get("quantity", 1)
|
|
price = it.get("unit_price", 0)
|
|
lines.append(f" {qty}x {name} -- ${price:,.2f}")
|
|
|
|
lines.append("---")
|
|
lines.append(f"Subtotal: ${subtotal:,.2f}")
|
|
lines.append(f"IVA: ${tax:,.2f}")
|
|
lines.append(f"*Total: ${total:,.2f}*")
|
|
lines.append(f"\nVigencia: {validity} dias")
|
|
|
|
notes = quote_data.get("notes")
|
|
if notes:
|
|
lines.append(f"Nota: {notes}")
|
|
|
|
return send_message(instance_name, to_phone, "\n".join(lines))
|
|
|
|
|
|
def send_order_confirmation(instance_name, to_phone, sale_data):
|
|
"""Send order / sale confirmation."""
|
|
biz = sale_data.get("business_name", "Nexus Autoparts")
|
|
folio = sale_data.get("folio", "--")
|
|
total = sale_data.get("total", 0)
|
|
method = sale_data.get("payment_method", "efectivo")
|
|
|
|
lines = [
|
|
f"*{biz}*",
|
|
f"Confirmacion de venta #{folio}",
|
|
"---",
|
|
]
|
|
for it in sale_data.get("items", []):
|
|
name = it.get("name", "Articulo")
|
|
qty = it.get("quantity", 1)
|
|
lines.append(f" {qty}x {name}")
|
|
|
|
lines.append("---")
|
|
lines.append(f"*Total: ${total:,.2f}*")
|
|
lines.append(f"Pago: {method}")
|
|
lines.append("\nGracias por su compra!")
|
|
|
|
return send_message(instance_name, to_phone, "\n".join(lines))
|
|
|
|
|
|
def send_stock_alert(instance_name, to_phone, alert_data):
|
|
"""Send stock alert to owner/manager."""
|
|
lines = [
|
|
"*ALERTA DE INVENTARIO*",
|
|
"Los siguientes articulos estan bajos en stock:",
|
|
"",
|
|
]
|
|
for it in alert_data.get("items", []):
|
|
name = it.get("name", "?")
|
|
current = it.get("current_stock", 0)
|
|
minimum = it.get("min_stock", 0)
|
|
lines.append(f" {name}: {current} uds (min: {minimum})")
|
|
|
|
lines.append("\nRevisa el inventario en tu POS.")
|
|
return send_message(instance_name, to_phone, "\n".join(lines))
|
|
|
|
|
|
# -- Incoming webhook processing ----------------------------------------------
|
|
|
|
def process_incoming(webhook_data):
|
|
"""Process incoming Evolution API webhook.
|
|
|
|
Evolution sends a different format than Meta Cloud API.
|
|
|
|
Returns:
|
|
dict with parsed message info.
|
|
"""
|
|
result = {'handled': False}
|
|
|
|
try:
|
|
data = webhook_data.get('data', {})
|
|
key = data.get('key', {})
|
|
message = data.get('message', {})
|
|
|
|
from_me = key.get('fromMe', False)
|
|
phone = key.get('remoteJid', '').replace('@s.whatsapp.net', '')
|
|
|
|
if not phone:
|
|
return result
|
|
|
|
# Extract text from different message types
|
|
text = ''
|
|
msg_type = 'text'
|
|
if 'conversation' in message:
|
|
text = message['conversation']
|
|
elif 'extendedTextMessage' in message:
|
|
text = message['extendedTextMessage'].get('text', '')
|
|
elif 'imageMessage' in message:
|
|
text = message['imageMessage'].get('caption', '[Imagen]')
|
|
msg_type = 'image'
|
|
elif 'documentMessage' in message:
|
|
text = message['documentMessage'].get('caption', '[Documento]')
|
|
msg_type = 'document'
|
|
elif 'audioMessage' in message:
|
|
text = '[Audio]'
|
|
msg_type = 'audio'
|
|
elif 'videoMessage' in message:
|
|
text = message['videoMessage'].get('caption', '[Video]')
|
|
msg_type = 'video'
|
|
elif 'contactMessage' in message:
|
|
text = '[Contacto]'
|
|
msg_type = 'contact'
|
|
elif 'locationMessage' in message:
|
|
text = '[Ubicacion]'
|
|
msg_type = 'location'
|
|
|
|
# Contact info from pushName
|
|
contact_name = data.get('pushName', '')
|
|
|
|
result = {
|
|
'type': 'message',
|
|
'phone': phone,
|
|
'contact_name': contact_name,
|
|
'text': text,
|
|
'message_type': msg_type,
|
|
'from_me': from_me,
|
|
'message_id': key.get('id', ''),
|
|
'timestamp': data.get('messageTimestamp', 0),
|
|
'handled': True,
|
|
}
|
|
|
|
# Skip auto-reply for our own outgoing messages
|
|
if from_me:
|
|
result['type'] = 'outgoing'
|
|
return result
|
|
|
|
# Attempt AI auto-response if the ai_chat service is available
|
|
try:
|
|
from services.ai_chat import chat as ai_chat_fn
|
|
ai_result = ai_chat_fn(text, [])
|
|
ai_reply = ai_result.get("message", "")
|
|
if ai_reply:
|
|
result["auto_reply"] = ai_reply
|
|
except Exception:
|
|
logger.debug("AI auto-reply not available, message queued for employee")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.exception("Error processing incoming Evolution webhook")
|
|
return {'error': str(e), 'handled': False}
|