Files
Autoparts-DB/pos/blueprints/whatsapp_bp.py
consultoria-as 36dd6634e3 feat(whatsapp): per-tenant WhatsApp configuration
- Refactor whatsapp_service.py to accept bridge_url parameter
- whatsapp_bp.py: remove hardcoded tenant_id=11, use g.tenant_id
- whatsapp_bp.py: webhook now accepts ?tenant_id param with fallback
- config_bp.py: add GET/PUT /config/whatsapp endpoints
- Each tenant can now have its own Baileys bridge URL and settings
2026-05-18 04:38:47 +00:00

783 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /home/Autopartes/pos/blueprints/whatsapp_bp.py
"""WhatsApp via Baileys Bridge.
Endpoints:
GET /pos/api/whatsapp/status -- Connection status
GET /pos/api/whatsapp/qr -- Get QR code
POST /pos/api/whatsapp/connect -- Start connection
POST /pos/api/whatsapp/logout -- Disconnect
POST /pos/api/whatsapp/webhook -- Receive messages (public)
POST /pos/api/whatsapp/send -- Send message
GET /pos/api/whatsapp/conversations -- List conversations
"""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _get_whatsapp_config(conn):
"""Read WhatsApp bridge configuration from tenant_config.
Returns dict with bridge_url, enabled, etc."""
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
config = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
return {
'bridge_url': config.get('whatsapp_bridge_url', ''),
'bridge_key': config.get('whatsapp_bridge_key', ''),
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
'phone_number': config.get('whatsapp_phone_number', ''),
}
def _resolve_mye_ids(vehicle, master_conn):
"""Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle:
return []
brand = vehicle.get('brand', '').strip()
model = vehicle.get('model', '').strip()
year = str(vehicle.get('year', '')).strip()
if not brand and not model:
return []
cur = master_conn.cursor()
clauses = []
params = []
if brand:
clauses.append("b.name_brand ILIKE %s")
params.append(f'%{brand}%')
if model:
clauses.append("m.name_model ILIKE %s")
params.append(f'%{model}%')
if year and year.isdigit():
clauses.append("y.year_car = %s")
params.append(int(year))
if not clauses:
cur.close()
return []
cur.execute(f"""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE {' AND '.join(clauses)}
LIMIT 50
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [r[0] for r in rows]
def _get_conversation_history(phone, tenant_conn, limit=4):
"""Fetch recent messages for *phone* to give the AI conversation context.
Includes both user and assistant messages, truncated to keep token count low.
The most recent message (the one currently being processed) is excluded.
"""
if not tenant_conn or not phone:
return []
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT direction, message_text
FROM whatsapp_messages
WHERE phone = %s
ORDER BY created_at DESC
LIMIT %s OFFSET 1
""", (phone, limit))
rows = cur.fetchall()
cur.close()
# Reverse so oldest-first (chronological) for the LLM
history = []
for direction, text in reversed(rows):
if not text:
continue
role = "assistant" if direction == "outgoing" else "user"
# Truncate assistant replies more aggressively (they contain JSON/tables)
max_len = 200 if role == "assistant" else 300
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
history.append({"role": role, "content": truncated})
return history
except Exception as e:
print(f"[WA-AI] Failed to load conversation history: {e}")
return []
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
If *vehicle* is provided and we have a master_conn, we first look up the
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
only show parts that are known to fit the user's car.
Returns:
(formatted_text, first_part_dict) — first_part_dict is used by the
quotation system to know what to add when the user says "cotizar".
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
"""
if not tenant_conn:
return None, None
try:
from services.translations import PART_TRANSLATIONS
# Split search_query by '|' into individual terms
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
if not raw_terms:
raw_terms = [search_query] if search_query else []
# Translate each term to Spanish if possible
search_terms = set()
for term in raw_terms:
search_terms.add(term)
# Check if any English translation matches
for en, es in PART_TRANSLATIONS.items():
if en.upper() == term.upper():
search_terms.add(es)
break
# Also check if the term contains an English word
if en.upper() in term.upper():
search_terms.add(term.upper().replace(en.upper(), es))
search_terms = list(search_terms)
if not search_terms:
return None, None
# Vehicle-aware filtering
mye_ids = _resolve_mye_ids(vehicle, master_conn)
def _do_search(use_compat=True):
"""Run inventory search. Returns list of rows."""
conditions = []
params = []
for term in search_terms:
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
like = f'%{term}%'
params.extend([like, like, like])
where_search = ' OR '.join(conditions)
compat_clause = ""
if use_compat and mye_ids:
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
params.extend(mye_ids)
cur = tenant_conn.cursor()
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
COALESCE(s.stock, 0) AS stock,
i.unit, i.location
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
{compat_clause}
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall()
cur.close()
return rows
# 1. Try with vehicle compatibility filter
rows = _do_search(use_compat=True)
compat_filter_applied = bool(mye_ids)
# 2. If no results with compatibility, try WITHOUT filter
fallback_rows = []
if not rows and mye_ids:
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Truly nothing found — return a conversational message that doesn't kill the chat
v_str = ""
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
msg_parts = [
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
]
if v_str:
msg_parts.append(f"Para tu {v_str}, puedo:")
else:
msg_parts.append("Te puedo ayudar de estas formas:")
msg_parts.extend([
"",
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
"• *Sugerir refaccionarias cercanas* — si es urgente",
"",
"¿Qué prefieres? O dime si quieres buscar otra parte."
])
return '\n'.join(msg_parts), None
# Use fallback rows if primary search returned nothing
using_fallback = False
if not rows and fallback_rows:
rows = fallback_rows
using_fallback = True
in_stock = [r for r in rows if r[7] > 0]
out_stock = [r for r in rows if r[7] <= 0]
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
first_part = None
if best:
first_part = {
'inventory_id': best[0],
'part_number': best[1],
'name': best[2],
'brand': best[3] or '',
'price': float(best[4]) if best[4] else 0,
'tax_rate': 0.16,
'stock': best[7],
'unit': best[8] or 'PZA',
}
lines = []
if using_fallback:
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
lines.append("")
if in_stock:
lines.append('✅ *Tenemos en stock:*')
lines.append('')
for r in in_stock:
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
lines.append(f'{brand_str} {name}')
lines.append(f' #{part_num}{price_str} ({stock} {unit or "pzas"} disponibles)')
lines.append('')
elif out_stock:
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
lines.append('')
for r in out_stock[:5]:
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else ''
lines.append(f'{brand_str} {name} #{part_num} {price_str}')
lines.append('')
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
# Vehicle context
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
lines.append(f'🚗 Vehículo: {v_str.strip()}')
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
return '\n'.join(lines), first_part
except Exception as e:
print(f"[WA-AI] Enrichment error: {e}")
import traceback
traceback.print_exc()
return None, None
return None, None
@whatsapp_bp.route('/status', methods=['GET'])
@require_auth()
def status():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/qr', methods=['GET'])
@require_auth()
def qr():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/connect', methods=['POST'])
@require_auth()
def connect():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/logout', methods=['POST'])
@require_auth()
def logout():
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/webhook', methods=['POST'])
def webhook():
"""Receive messages from Baileys bridge (public, no auth).
Flow:
1. Persist the incoming message to the tenant's whatsapp_messages log.
2. Build inventory context for the AI (what this tenant has in stock).
3. Ask the chatbot for a reply, enriched with that context.
4. Send the reply back via the Baileys bridge.
"""
data = request.get_json(force=True, silent=True) or {}
if data.get('event') != 'messages.upsert':
return jsonify({'ok': True})
msg = whatsapp_service.process_incoming(data)
if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True})
# Resolve tenant: try query param first, then fallback to first enabled tenant
tenant_id = request.args.get('tenant_id', type=int)
if not tenant_id:
# Fallback: find first tenant with whatsapp enabled
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("""
SELECT t.id FROM tenants t
JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true'
WHERE t.is_active = true
ORDER BY t.id LIMIT 1
""")
row = mcur.fetchone()
mcur.close()
mconn.close()
tenant_id = row[0] if row else None
except Exception:
tenant_id = None
tenant_conn = None
master_conn = None
inventory_context = None
wa_config = {}
try:
tenant_conn = get_tenant_conn(tenant_id)
master_conn = get_master_conn()
wa_config = _get_whatsapp_config(tenant_conn)
# 1. Log the incoming message (with contact display name)
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s)
ON CONFLICT DO NOTHING
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
tenant_conn.commit()
cur.close()
# 2. Build inventory context once per webhook call so the chatbot
# can say things like "tengo 5 Bosch BP-123 por $450".
try:
from services.ai_chat import get_inventory_context
inventory_context = get_inventory_context(tenant_conn)
except Exception as e:
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2b. Append previously-detected vehicle so the AI keeps context
# even when we don't send full conversation history (Hermes is slow with it)
try:
from services.wa_quotation import get_vehicle
saved_vehicle = get_vehicle(clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
elif saved_vehicle:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
except Exception as e:
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
# 3. Dispatch by media kind + quotation commands
reply = None
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
# ── Check for quotation commands FIRST (before AI) ──
if media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
)
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if not qid:
qid = create_quotation(tenant_conn, clean_phone)
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
detail = get_quotation_detail(tenant_conn, qid)
item_count = len(detail['items']) if detail else 0
reply = (
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
elif intent == 'send':
if tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if qid:
detail = get_quotation_detail(tenant_conn, qid)
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
elif intent == 'clear':
if tenant_conn:
clear_quotation(tenant_conn, clean_phone)
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
elif intent == 'confirm':
if tenant_conn:
qid = confirm_quotation(tenant_conn, clean_phone)
if qid:
reply = (
f'✅ *Pedido confirmado!*\n\n'
f'Tu cotización #{qid} fue registrada.\n'
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
f'¡Gracias por tu compra! 🙏'
)
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
# ── Check for conversation reset commands ──
if media_kind == 'text' and msg.get('text'):
txt_lower = msg['text'].lower().strip()
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
if tenant_conn:
try:
cur_del = tenant_conn.cursor()
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
tenant_conn.commit()
cur_del.close()
except Exception as del_err:
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
if intent is not None:
# It was a quote command — send reply and skip the AI
if reply:
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
# Clean up and return early
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
try:
if media_kind == 'image' and msg.get('media_base64'):
from services.ai_chat import chat_with_image
# Prompt: use the caption if provided, else default to
# "identify this part" which chat_with_image handles gracefully.
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
ai_resp = chat_with_image(
user_message=prompt,
image_base64=msg['media_base64'],
conversation_history=conversation_history,
inventory_context=inventory_context,
)
reply = ai_resp.get('message', '') or ''
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
elif media_kind == 'audio' and msg.get('media_base64'):
# Voice note handling — transcribe first, then chat().
# See services.whisper_local for the transcriber.
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
except ImportError:
transcript = None
print("[WA-AI] whisper_local not installed — voice notes skipped")
except Exception as e:
transcript = None
print(f"[WA-AI] Whisper transcription failed: {e}")
if transcript:
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
from services.ai_chat import chat
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Prefix the reply so the sender knows we understood the voice note
if reply:
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
else:
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
'Puedes escribirme el mensaje?')
elif msg.get('text'):
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
if reply:
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
# Save the bot's reply to DB so it shows in the WhatsApp UI
if tenant_conn:
try:
cur2 = tenant_conn.cursor()
cur2.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (msg['phone'], reply))
tenant_conn.commit()
cur2.close()
except Exception as db_err:
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
# 4. Clean up connections
if tenant_conn is not None:
try:
tenant_conn.close()
except Exception:
pass
if master_conn is not None:
try:
master_conn.close()
except Exception:
pass
return jsonify({'ok': True})
@whatsapp_bp.route('/send', methods=['POST'])
@require_auth()
def send():
data = request.get_json() or {}
phone = data.get('phone', '')
message = data.get('message', '')
if not phone or not message:
return jsonify({'error': 'phone and message required'}), 400
# Load tenant WhatsApp config
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
if not cfg['enabled'] or not cfg['bridge_url']:
conn.close()
return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400
result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url'])
# Save outgoing message
try:
cur = conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (phone, message))
conn.commit()
cur.close()
except Exception:
pass
finally:
conn.close()
return jsonify(result)
@whatsapp_bp.route('/conversations', methods=['GET'])
@require_auth()
def conversations():
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
# so all variants of the same number are grouped together.
cur.execute("""
WITH cleaned AS (
SELECT
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
message_text,
direction,
created_at,
push_name
FROM whatsapp_messages
)
SELECT clean_phone,
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
MAX(created_at) AS last_at,
COUNT(*) AS msg_count,
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
FROM cleaned
GROUP BY clean_phone
ORDER BY MAX(created_at) DESC
LIMIT 50
""")
convos = [{
'phone': r[0],
'last_message': r[1] or '',
'last_direction': r[2] or 'incoming',
'last_at': str(r[3]),
'count': r[4],
'contact_name': r[5] or '',
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'conversations': convos})
except Exception as e:
return jsonify({'conversations': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
@require_auth()
def conversation_messages(phone):
# Strip @lid or @s.whatsapp.net suffix for DB lookup
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Match all variants of this phone number
cur.execute("""
SELECT id, direction, message_text, created_at
FROM whatsapp_messages
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
ORDER BY created_at
LIMIT 100
""", (clean_phone,))
msgs = [{
'id': r[0],
'direction': r[1],
'message_text': r[2] or '',
'created_at': str(r[3]),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'messages': msgs})
except Exception as e:
return jsonify({'messages': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
@require_auth()
def delete_conversation(phone):
"""Delete all messages for a phone number."""
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
DELETE FROM whatsapp_messages
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
""", (clean_phone,))
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500
@whatsapp_bp.route('/conversations', methods=['DELETE'])
@require_auth()
def delete_all_conversations():
"""Delete ALL whatsapp messages."""
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("DELETE FROM whatsapp_messages")
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500