feat(pos): WhatsApp Business API integration — send/receive messages, quotations
Add full WhatsApp Cloud API integration for Nexus POS: - Service layer (whatsapp_service.py): send text, templates, quotations, order confirmations, stock alerts; process incoming webhooks with AI auto-reply - Blueprint (whatsapp_bp.py): public webhook endpoints for Meta verification + incoming messages; authenticated endpoints for send, send-quote, conversations - Conversation UI (whatsapp.html + whatsapp.js): split-panel messenger with conversation list, chat bubbles, send input, quote sending; both themes - Migration v1.4: whatsapp_messages table with phone/direction/status indexes - Config: WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, WHATSAPP_VERIFY_TOKEN env vars - Sidebar: WhatsApp nav item under Gestion with message-bubble icon - Ready for Meta Business credentials (infrastructure complete, no API keys needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
307
pos/services/whatsapp_service.py
Normal file
307
pos/services/whatsapp_service.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""WhatsApp Business API service via Meta Cloud API.
|
||||
|
||||
Handles sending and receiving WhatsApp messages.
|
||||
Uses Meta's Cloud API (free for 1,000 service conversations/month).
|
||||
|
||||
Setup:
|
||||
1. Create a Meta Business account at business.facebook.com
|
||||
2. Create a WhatsApp Business app at developers.facebook.com
|
||||
3. Get a permanent access token and Phone Number ID
|
||||
4. Set environment variables:
|
||||
WHATSAPP_TOKEN=<your-permanent-token>
|
||||
WHATSAPP_PHONE_ID=<your-phone-number-id>
|
||||
WHATSAPP_VERIFY_TOKEN=<your-chosen-verify-string>
|
||||
5. Configure the webhook URL in Meta Developer Console:
|
||||
https://<your-domain>/pos/api/whatsapp/webhook
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from config import WHATSAPP_TOKEN, WHATSAPP_PHONE_ID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_VERSION = "v18.0"
|
||||
BASE_URL = f"https://graph.facebook.com/{API_VERSION}"
|
||||
|
||||
|
||||
def _headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {WHATSAPP_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def is_configured():
|
||||
"""Return True if WhatsApp credentials are set."""
|
||||
return bool(WHATSAPP_TOKEN and WHATSAPP_PHONE_ID)
|
||||
|
||||
|
||||
# ── Sending ──────────────────────────────────────────────────────────────
|
||||
|
||||
def send_message(to_phone, message_text):
|
||||
"""Send a plain-text message via WhatsApp Cloud API.
|
||||
|
||||
Args:
|
||||
to_phone: recipient phone in E.164 format (e.g. '5215512345678')
|
||||
message_text: text content
|
||||
|
||||
Returns:
|
||||
dict with 'wa_message_id' on success or 'error' on failure.
|
||||
"""
|
||||
if not is_configured():
|
||||
return {"error": "WhatsApp not configured — set WHATSAPP_TOKEN and WHATSAPP_PHONE_ID"}
|
||||
|
||||
url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages"
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": to_phone,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": message_text},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=_headers(), timeout=15)
|
||||
data = resp.json()
|
||||
if resp.status_code in (200, 201):
|
||||
wa_id = data.get("messages", [{}])[0].get("id", "")
|
||||
return {"wa_message_id": wa_id}
|
||||
else:
|
||||
err = data.get("error", {}).get("message", resp.text)
|
||||
logger.error("WhatsApp send failed: %s", err)
|
||||
return {"error": err}
|
||||
except Exception as e:
|
||||
logger.exception("WhatsApp send exception")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def send_template(to_phone, template_name, parameters=None):
|
||||
"""Send a pre-approved template message (for initiating conversations).
|
||||
|
||||
Args:
|
||||
to_phone: E.164 phone number
|
||||
template_name: approved template name (e.g. 'order_confirmation')
|
||||
parameters: list of {"type": "text", "text": "value"} dicts
|
||||
"""
|
||||
if not is_configured():
|
||||
return {"error": "WhatsApp not configured"}
|
||||
|
||||
url = f"{BASE_URL}/{WHATSAPP_PHONE_ID}/messages"
|
||||
components = []
|
||||
if parameters:
|
||||
components.append({
|
||||
"type": "body",
|
||||
"parameters": parameters,
|
||||
})
|
||||
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_phone,
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template_name,
|
||||
"language": {"code": "es_MX"},
|
||||
"components": components,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=_headers(), timeout=15)
|
||||
data = resp.json()
|
||||
if resp.status_code in (200, 201):
|
||||
wa_id = data.get("messages", [{}])[0].get("id", "")
|
||||
return {"wa_message_id": wa_id}
|
||||
else:
|
||||
err = data.get("error", {}).get("message", resp.text)
|
||||
logger.error("WhatsApp template send failed: %s", err)
|
||||
return {"error": err}
|
||||
except Exception as e:
|
||||
logger.exception("WhatsApp template send exception")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def send_quote(to_phone, quote_data):
|
||||
"""Send a formatted quotation to a customer.
|
||||
|
||||
Args:
|
||||
to_phone: E.164 phone number
|
||||
quote_data: dict with keys: business_name, quote_number, items[], subtotal,
|
||||
tax, total, validity_days, notes (optional)
|
||||
"""
|
||||
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(to_phone, "\n".join(lines))
|
||||
|
||||
|
||||
def send_order_confirmation(to_phone, sale_data):
|
||||
"""Send order / sale confirmation.
|
||||
|
||||
Args:
|
||||
to_phone: E.164 phone number
|
||||
sale_data: dict with keys: business_name, folio, items[], total, payment_method
|
||||
"""
|
||||
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(to_phone, "\n".join(lines))
|
||||
|
||||
|
||||
def send_stock_alert(to_phone, alert_data):
|
||||
"""Send stock alert to owner/manager.
|
||||
|
||||
Args:
|
||||
to_phone: E.164 phone
|
||||
alert_data: dict with keys: items[] (each has name, current_stock, min_stock)
|
||||
"""
|
||||
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(to_phone, "\n".join(lines))
|
||||
|
||||
|
||||
# ── Incoming webhook processing ──────────────────────────────────────────
|
||||
|
||||
def process_incoming(webhook_data):
|
||||
"""Process incoming WhatsApp webhook message.
|
||||
|
||||
Routes to AI chatbot for auto-response or queues for employee.
|
||||
|
||||
Args:
|
||||
webhook_data: the full webhook payload from Meta
|
||||
|
||||
Returns:
|
||||
dict with parsed message info: phone, text, wa_message_id, response (if auto-replied)
|
||||
"""
|
||||
result = {"handled": False}
|
||||
|
||||
try:
|
||||
entry = webhook_data.get("entry", [])
|
||||
if not entry:
|
||||
return result
|
||||
|
||||
changes = entry[0].get("changes", [])
|
||||
if not changes:
|
||||
return result
|
||||
|
||||
value = changes[0].get("value", {})
|
||||
|
||||
# Handle status updates (delivered, read, etc.)
|
||||
statuses = value.get("statuses", [])
|
||||
if statuses:
|
||||
return {
|
||||
"type": "status_update",
|
||||
"statuses": statuses,
|
||||
"handled": True,
|
||||
}
|
||||
|
||||
messages = value.get("messages", [])
|
||||
if not messages:
|
||||
return result
|
||||
|
||||
msg = messages[0]
|
||||
phone = msg.get("from", "")
|
||||
wa_message_id = msg.get("id", "")
|
||||
msg_type = msg.get("type", "text")
|
||||
|
||||
text = ""
|
||||
if msg_type == "text":
|
||||
text = msg.get("text", {}).get("body", "")
|
||||
elif msg_type == "interactive":
|
||||
interactive = msg.get("interactive", {})
|
||||
if interactive.get("type") == "button_reply":
|
||||
text = interactive.get("button_reply", {}).get("title", "")
|
||||
elif interactive.get("type") == "list_reply":
|
||||
text = interactive.get("list_reply", {}).get("title", "")
|
||||
else:
|
||||
text = f"[{msg_type} message]"
|
||||
|
||||
# Contact info
|
||||
contacts = value.get("contacts", [])
|
||||
contact_name = ""
|
||||
if contacts:
|
||||
profile = contacts[0].get("profile", {})
|
||||
contact_name = profile.get("name", "")
|
||||
|
||||
result = {
|
||||
"type": "message",
|
||||
"phone": phone,
|
||||
"contact_name": contact_name,
|
||||
"text": text,
|
||||
"message_type": msg_type,
|
||||
"wa_message_id": wa_message_id,
|
||||
"handled": True,
|
||||
}
|
||||
|
||||
# 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:
|
||||
send_result = send_message(phone, ai_reply)
|
||||
result["auto_reply"] = ai_reply
|
||||
result["auto_reply_wa_id"] = send_result.get("wa_message_id", "")
|
||||
except Exception:
|
||||
# AI not available — message stays in queue for employee
|
||||
logger.debug("AI auto-reply not available, message queued for employee")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing incoming WhatsApp message")
|
||||
return {"error": str(e), "handled": False}
|
||||
Reference in New Issue
Block a user