- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py - Update sidebar.js to filter nav items based on enabled modules - Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre - Add module load/save logic to POS config.js - Preload modules in app-init.js for sidebar caching - Add tenant module management to Instance Manager - get_tenant_modules / update_tenant_modules in tenant_service.py - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py - Add modules modal to manager index.html - Add module editing UI and logic to manager.js - Add toggle-switch CSS to manager.css
734 lines
27 KiB
Python
734 lines
27 KiB
Python
# /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
|
|
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
|
|
from datetime import datetime
|
|
|
|
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
|
|
|
|
|
def _get_whatsapp_config(conn):
|
|
"""Read WhatsApp bridge configuration from tenant_config.
|
|
Falls back to global server config (config.py / env vars) when tenant
|
|
has no explicit WhatsApp settings. This allows the shared bridge to work
|
|
out of the box for all tenants.
|
|
"""
|
|
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()
|
|
|
|
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
|
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
|
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
|
if enabled_raw == 'true':
|
|
enabled = True
|
|
elif enabled_raw == 'false':
|
|
enabled = False
|
|
else:
|
|
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
|
enabled = bool(bridge_url)
|
|
|
|
return {
|
|
'bridge_url': bridge_url,
|
|
'bridge_key': bridge_key,
|
|
'enabled': enabled,
|
|
'phone_number': config.get('whatsapp_phone_number', ''),
|
|
}
|
|
|
|
|
|
def _get_branch_phone(tenant_conn, branch_id=None):
|
|
"""Obtener teléfono de la sucursal."""
|
|
if not tenant_conn:
|
|
return '(pendiente)'
|
|
try:
|
|
cur = tenant_conn.cursor()
|
|
if branch_id:
|
|
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
|
|
row = cur.fetchone()
|
|
if row and row[0]:
|
|
cur.close()
|
|
return row[0]
|
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
return row[0] if row and row[0] else '(pendiente)'
|
|
except Exception as e:
|
|
print(f"[WA-SM] get_branch_phone error: {e}")
|
|
return '(pendiente)'
|
|
|
|
|
|
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:
|
|
# Nothing found in local inventory — let the AI's original response stand.
|
|
# The webhook will append a soft note instead of replacing the message.
|
|
return None, 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).
|
|
|
|
Nuevo flujo: máquina de estados estructurada.
|
|
"""
|
|
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})
|
|
|
|
phone = msg['phone']
|
|
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
|
|
text = msg.get('text', '')
|
|
media_kind = msg.get('media_kind', 'text')
|
|
|
|
# Audio transcription (voice notes)
|
|
if media_kind == 'audio' and msg.get('media_base64'):
|
|
try:
|
|
from services.whisper_local import transcribe_audio_base64
|
|
transcript = transcribe_audio_base64(
|
|
msg['media_base64'],
|
|
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
|
)
|
|
if transcript:
|
|
text = transcript
|
|
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
print(f"[WA-SM] Whisper transcription failed: {e}")
|
|
|
|
# Location message: if current state expects it, store coordinates
|
|
if media_kind == 'location' and msg.get('latitude') is not None:
|
|
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
|
|
|
|
# Image without caption: provide a default text so the state machine can handle it
|
|
if media_kind == 'image' and not text:
|
|
text = "(imagen)"
|
|
|
|
|
|
# Resolve tenant
|
|
tenant_id = request.args.get('tenant_id', type=int)
|
|
if not tenant_id:
|
|
try:
|
|
mconn = get_master_conn()
|
|
mcur = mconn.cursor()
|
|
mcur.execute("""
|
|
SELECT id, db_name FROM tenants
|
|
WHERE is_active = true
|
|
ORDER BY id
|
|
""")
|
|
tenants = mcur.fetchall()
|
|
mcur.close()
|
|
mconn.close()
|
|
# Find first tenant with whatsapp_enabled in their config
|
|
for tid, db_name in tenants:
|
|
try:
|
|
from tenant_db import get_tenant_conn_by_dbname
|
|
tconn = get_tenant_conn_by_dbname(db_name)
|
|
tcur = tconn.cursor()
|
|
tcur.execute(
|
|
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
|
|
)
|
|
row = tcur.fetchone()
|
|
tcur.close()
|
|
tconn.close()
|
|
if row and row[0].lower() == 'true':
|
|
tenant_id = tid
|
|
break
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
tenant_id = None
|
|
|
|
tenant_conn = None
|
|
master_conn = None
|
|
|
|
try:
|
|
tenant_conn = get_tenant_conn(tenant_id)
|
|
master_conn = get_master_conn()
|
|
wa_config = _get_whatsapp_config(tenant_conn)
|
|
|
|
# Deduplicate by wa_message_id
|
|
wa_message_id = msg.get('message_id')
|
|
if wa_message_id:
|
|
cur = tenant_conn.cursor()
|
|
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
|
|
if cur.fetchone():
|
|
cur.close()
|
|
return jsonify({'ok': True})
|
|
cur.close()
|
|
|
|
# 1. Log incoming message
|
|
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
|
|
""", (phone, text, wa_message_id, msg.get('push_name')))
|
|
tenant_conn.commit()
|
|
cur.close()
|
|
|
|
# 2. Load session state
|
|
from services.wa_state_machine import get_session, save_session, process_message, StateContext
|
|
session = get_session(tenant_conn, phone)
|
|
|
|
# 3. Check session expiry (30 minutes)
|
|
current_state = session.get('state', 'idle')
|
|
state_data = session.get('state_data', {})
|
|
last_updated = session.get('updated_at')
|
|
|
|
if last_updated and hasattr(last_updated, 'strftime'):
|
|
# PostgreSQL returns datetime objects (often timezone-aware)
|
|
from datetime import timezone
|
|
now = datetime.now(timezone.utc)
|
|
if last_updated.tzinfo is None:
|
|
now = now.replace(tzinfo=None)
|
|
elapsed = (now - last_updated).total_seconds()
|
|
if elapsed > 1800:
|
|
current_state = 'idle'
|
|
state_data = {'customer_id': state_data.get('customer_id')}
|
|
elif last_updated and isinstance(last_updated, str):
|
|
from datetime import datetime as dt
|
|
try:
|
|
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
|
|
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
|
|
if elapsed > 1800:
|
|
current_state = 'idle'
|
|
state_data = {'customer_id': state_data.get('customer_id')}
|
|
except Exception:
|
|
pass
|
|
|
|
# Global reset commands work from any state
|
|
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
|
|
current_state = 'idle'
|
|
state_data = {'customer_id': state_data.get('customer_id')}
|
|
|
|
# Abandoned quotation follow-up
|
|
try:
|
|
from services.part_kits import should_send_followup
|
|
followup = should_send_followup(phone, tenant_conn)
|
|
if followup:
|
|
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
|
cur_fu = tenant_conn.cursor()
|
|
cur_fu.execute(
|
|
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
|
(phone, followup)
|
|
)
|
|
tenant_conn.commit()
|
|
cur_fu.close()
|
|
except Exception as fu_err:
|
|
print(f"[WA-SM] Follow-up send failed: {fu_err}")
|
|
|
|
# 4. Build context
|
|
context = StateContext(
|
|
tenant_conn=tenant_conn,
|
|
master_conn=master_conn,
|
|
wa_config=wa_config,
|
|
tenant_id=tenant_id,
|
|
phone=phone,
|
|
media_kind=media_kind,
|
|
media_base64=msg.get('media_base64'),
|
|
push_name=msg.get('push_name'),
|
|
)
|
|
|
|
# 5. Process through state machine
|
|
reply, next_state, next_state_data = process_message(
|
|
phone=phone,
|
|
text=text,
|
|
current_state=current_state,
|
|
state_data=state_data,
|
|
context=context,
|
|
)
|
|
|
|
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
|
|
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
|
|
loop_guard = 0
|
|
while reply is None and loop_guard < 5:
|
|
loop_guard += 1
|
|
reply, next_state, next_state_data = process_message(
|
|
phone=phone,
|
|
text=text,
|
|
current_state=next_state,
|
|
state_data=next_state_data,
|
|
context=context,
|
|
)
|
|
|
|
# 6. Save new state
|
|
save_session(tenant_conn, phone, next_state, next_state_data)
|
|
|
|
# 7. Send reply
|
|
if reply:
|
|
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
|
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
|
|
|
# Log outgoing
|
|
cur = tenant_conn.cursor()
|
|
cur.execute("""
|
|
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
|
VALUES (%s, 'outgoing', %s)
|
|
""", (phone, reply))
|
|
tenant_conn.commit()
|
|
cur.close()
|
|
|
|
except Exception as e:
|
|
print(f"[WA-SM] Webhook error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
# Fallback: enviar mensaje de error genérico
|
|
try:
|
|
if tenant_conn:
|
|
phone_branch = _get_branch_phone(tenant_conn, None)
|
|
fallback = (
|
|
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
|
f"Por favor llámanos directamente al {phone_branch}."
|
|
)
|
|
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
|
except Exception:
|
|
pass
|
|
|
|
finally:
|
|
if tenant_conn:
|
|
try:
|
|
tenant_conn.close()
|
|
except Exception:
|
|
pass
|
|
if master_conn:
|
|
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
|