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>
309 lines
10 KiB
Python
309 lines
10 KiB
Python
# /home/Autopartes/pos/blueprints/whatsapp_bp.py
|
|
"""WhatsApp Business 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)
|
|
"""
|
|
|
|
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__)
|
|
|
|
whatsapp_bp = Blueprint("whatsapp", __name__, url_prefix="/pos/api/whatsapp")
|
|
|
|
|
|
# ── Webhook (PUBLIC — no auth, Meta must be able to call these) ──────────
|
|
|
|
@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", "")
|
|
|
|
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("/webhook", methods=["POST"])
|
|
def webhook_receive():
|
|
"""Receive incoming WhatsApp messages from Meta.
|
|
|
|
Stores in DB (using tenant_id=1 as default for webhook context)
|
|
and triggers auto-response via AI chatbot if available.
|
|
"""
|
|
payload = request.get_json(silent=True)
|
|
if not payload:
|
|
return "OK", 200
|
|
|
|
result = whatsapp_service.process_incoming(payload)
|
|
|
|
# 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("wa_message_id", ""),
|
|
status="received",
|
|
)
|
|
|
|
# Store auto-reply if one was generated
|
|
if result.get("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",
|
|
)
|
|
|
|
# Always return 200 to Meta (otherwise they retry)
|
|
return "OK", 200
|
|
|
|
|
|
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()
|
|
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
|
|
|
|
result = whatsapp_service.send_message(phone, text)
|
|
|
|
if "error" not in result:
|
|
_store_message(
|
|
phone=phone,
|
|
direction="outgoing",
|
|
message_text=text,
|
|
wa_message_id=result.get("wa_message_id", ""),
|
|
status="sent",
|
|
tenant_id=g.tenant_id,
|
|
)
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
@whatsapp_bp.route("/send-quote/<int:quote_id>", 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
|
|
|
|
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
|
|
WHERE id = %s AND sale_type = 'quotation'
|
|
""", (quote_id,))
|
|
quote = cur.fetchone()
|
|
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
|
|
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],
|
|
})
|
|
|
|
# Load tenant business name
|
|
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(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", ""),
|
|
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,
|
|
})
|
|
|
|
# Sort by most recent first
|
|
conversations.sort(key=lambda c: c["last_at"] or "", reverse=True)
|
|
|
|
return jsonify({"conversations": conversations})
|
|
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|
|
|
|
|
|
@whatsapp_bp.route("/conversations/<phone>", 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,
|
|
})
|
|
|
|
# Also get total count
|
|
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()
|