Files
Autoparts-DB/pos/blueprints/whatsapp_bp.py
consultoria-as 718fa06888 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
2026-05-28 00:21:52 +00:00

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