feat: module toggles in POS config and Instance Manager
- 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
This commit is contained in:
@@ -579,6 +579,58 @@ def update_whatsapp_config():
|
||||
return jsonify({'message': 'WhatsApp configuration updated'})
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def _bool(key):
|
||||
return rows.get(key, 'true').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'whatsapp': _bool('module_whatsapp'),
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_modules():
|
||||
"""Update enabled modules for this tenant."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||
'module_meli': 'true' if data.get('meli') else 'false',
|
||||
}
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, value))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'message': 'Modules updated', 'modules': {
|
||||
'whatsapp': data.get('whatsapp'),
|
||||
'marketplace': data.get('marketplace'),
|
||||
'meli': data.get('meli'),
|
||||
}})
|
||||
|
||||
|
||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_onboarding_status():
|
||||
|
||||
@@ -1864,6 +1864,14 @@ def complete_layaway(layaway_id):
|
||||
new_value={'sale_id': sale['id'], 'total': total})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
check_learning_resolution(sale['id'], cust_id, conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify(sale), 201
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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')
|
||||
|
||||
@@ -50,6 +51,27 @@ def _get_whatsapp_config(conn):
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -329,11 +351,7 @@ def logout():
|
||||
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.
|
||||
Nuevo flujo: máquina de estados estructurada.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
@@ -344,421 +362,227 @@ def webhook():
|
||||
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
|
||||
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:
|
||||
# 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
|
||||
SELECT id, db_name FROM tenants
|
||||
WHERE is_active = true
|
||||
ORDER BY id
|
||||
""")
|
||||
row = mcur.fetchone()
|
||||
tenants = mcur.fetchall()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
tenant_id = row[0] if row else None
|
||||
# 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
|
||||
|
||||
# Prepare phone and reply target early
|
||||
reply_to = msg.get('jid') or msg['phone']
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
|
||||
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)
|
||||
# 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
|
||||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||||
""", (phone, text, wa_message_id, msg.get('push_name')))
|
||||
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
|
||||
# 2. Load session state
|
||||
from services.wa_state_machine import get_session, save_session, process_message, StateContext
|
||||
session = get_session(tenant_conn, phone)
|
||||
|
||||
# 2c. Urgency detection — if customer signals urgency, add a note
|
||||
try:
|
||||
from services.part_kits import is_urgent, urgency_note
|
||||
if msg.get('text') and is_urgent(msg['text']):
|
||||
if inventory_context:
|
||||
inventory_context += urgency_note()
|
||||
else:
|
||||
inventory_context = urgency_note().strip()
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] urgency detection failed: {e}")
|
||||
# 3. Check session expiry (30 minutes)
|
||||
current_state = session.get('state', 'idle')
|
||||
state_data = session.get('state_data', {})
|
||||
last_updated = session.get('updated_at')
|
||||
|
||||
# 2d. Purchase history — append recent confirmed orders for this customer
|
||||
try:
|
||||
from services.part_kits import get_purchase_history
|
||||
history = get_purchase_history(clean_phone, tenant_conn)
|
||||
if history:
|
||||
if inventory_context:
|
||||
inventory_context += "\n\n" + history
|
||||
else:
|
||||
inventory_context = history
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Purchase history failed: {e}")
|
||||
|
||||
# 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(tenant_conn, 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}")
|
||||
if tenant_conn:
|
||||
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:
|
||||
tenant_conn.rollback()
|
||||
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
|
||||
|
||||
# 3. Dispatch by media kind + quotation commands
|
||||
reply = None
|
||||
# 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 ──
|
||||
# If customer has an active quote and hasn't interacted in 15+ min,
|
||||
# send a gentle nudge before processing their current message.
|
||||
try:
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(clean_phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
if tenant_conn:
|
||||
# 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)",
|
||||
(clean_phone, followup)
|
||||
(phone, followup)
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur_fu.close()
|
||||
except Exception as fu_err:
|
||||
print(f"[WA-AI] Follow-up send failed: {fu_err}")
|
||||
except Exception as fu_err:
|
||||
print(f"[WA-SM] Follow-up send failed: {fu_err}")
|
||||
|
||||
# ── Location message → nearest branch ──
|
||||
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
|
||||
from services.geo_branches import find_nearest_branch
|
||||
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
|
||||
if nearest:
|
||||
reply = (
|
||||
f"📍 *Sucursal más cercana:*\n\n"
|
||||
f"*{nearest['name']}*\n"
|
||||
f"📌 {nearest['address']}\n"
|
||||
f"📞 {nearest['phone']}\n"
|
||||
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
|
||||
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
|
||||
)
|
||||
else:
|
||||
reply = (
|
||||
"📍 Gracias por tu ubicación.\n\n"
|
||||
"Actualmente no tenemos sucursales registradas con coordenadas. "
|
||||
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
|
||||
)
|
||||
|
||||
# ── Check for quotation commands FIRST (before AI) ──
|
||||
if not reply and 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,
|
||||
# 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'),
|
||||
)
|
||||
from services.quote_image import generate_quote_image
|
||||
from services.whatsapp_service import send_image
|
||||
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(tenant_conn, 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._'
|
||||
)
|
||||
# Smart kit suggestion — cross-sell related parts
|
||||
try:
|
||||
from services.part_kits import build_kit_text
|
||||
kit_text = build_kit_text(last_part.get('name', ''))
|
||||
if kit_text:
|
||||
reply += kit_text
|
||||
except Exception as kit_err:
|
||||
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
|
||||
# 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,
|
||||
)
|
||||
|
||||
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:
|
||||
# Generate rich visual quote image and send it
|
||||
try:
|
||||
quote_items = []
|
||||
for it in detail.get('items', []):
|
||||
quote_items.append({
|
||||
'name': it.get('name', ''),
|
||||
'sku': it.get('sku', ''),
|
||||
'qty': it.get('quantity', 1),
|
||||
'price': float(it.get('unit_price', 0)),
|
||||
'total': float(it.get('total', 0)),
|
||||
})
|
||||
totals = {
|
||||
'subtotal': float(detail.get('subtotal', 0)),
|
||||
'tax': float(detail.get('tax', 0)),
|
||||
'total': float(detail.get('total', 0)),
|
||||
}
|
||||
tenant_name = tenant_config.get('business_name', 'Autopartes')
|
||||
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
|
||||
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
|
||||
if img_result.get('success'):
|
||||
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
|
||||
else:
|
||||
print(f"[WA-AI] Image send failed: {img_result}")
|
||||
except Exception as img_err:
|
||||
print(f"[WA-AI] Quote image generation failed: {img_err}")
|
||||
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=4)
|
||||
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,
|
||||
# 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,
|
||||
)
|
||||
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}")
|
||||
# 6. Save new state
|
||||
save_session(tenant_conn, phone, next_state, next_state_data)
|
||||
|
||||
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'):
|
||||
txt = msg['text'].strip().lower()
|
||||
# Quick welcome menu for new customers with no vehicle
|
||||
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
|
||||
if is_greeting:
|
||||
try:
|
||||
from services.wa_quotation import get_vehicle
|
||||
veh = get_vehicle(tenant_conn, clean_phone)
|
||||
if not veh:
|
||||
reply = (
|
||||
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
|
||||
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
|
||||
"1️⃣ *Marca, modelo y año* de tu vehículo\n"
|
||||
"2️⃣ La *parte* que necesitas\n"
|
||||
"3️⃣ O escribe *menú* para ver opciones\n\n"
|
||||
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not reply:
|
||||
# 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(tenant_conn, 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
|
||||
elif not found_part and vehicle and vehicle.get('brand'):
|
||||
# Only say "not in stock" when we have a specific vehicle
|
||||
# and still found nothing. Otherwise let the AI ask for vehicle info.
|
||||
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
|
||||
# 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(tenant_conn, clean_phone, found_part)
|
||||
except Exception as enrich_err:
|
||||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||||
|
||||
# Send reply if we produced one
|
||||
# 7. Send reply
|
||||
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}")
|
||||
print(f"[WA-SM] Replied to {phone}: {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}")
|
||||
# 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-AI] Error handling {media_kind} from {reply_to}: {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
|
||||
|
||||
# 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
|
||||
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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user