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:
2026-05-28 00:21:52 +00:00
parent 999591e248
commit 718fa06888
26 changed files with 2614 additions and 429 deletions

View File

@@ -11,6 +11,9 @@ if not DB_URL:
"Example: postgresql://user:pass@localhost/nexus_autoparts"
)
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or DB_URL
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") or DB_URL.replace("nexus_autoparts", "{db_name}")
# Legacy SQLite path (used only by migration script)
SQLITE_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),

View File

@@ -92,6 +92,14 @@
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
</div>
</div>
<div class="sidebar-section">
<h3>Tenants</h3>
<div class="sidebar-item" data-section="tenants">
<span class="icon">🏢</span>
<span>Módulos</span>
</div>
</div>
</aside>
<!-- Main Content -->
@@ -660,6 +668,35 @@
</div>
</section>
<!-- Tenants / Modules Section -->
<section id="section-tenants" class="admin-section">
<div class="page-header">
<h1 class="page-title">Configuración de Módulos por Tenant</h1>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Tenants Activos</h2>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>WhatsApp</th>
<th>Marketplace</th>
<th>MercadoLibre</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="tenantsTable">
<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
</div>

View File

@@ -121,6 +121,9 @@ function showSection(sectionId) {
case 'users':
loadUsers();
break;
case 'tenants':
loadTenants();
break;
}
}
@@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) {
showAlert(e.message, 'error');
}
}
// ─── Tenants / Modules ─────────────────────────────────────────────────────
async function loadTenants() {
var token = localStorage.getItem('access_token');
var tbody = document.getElementById('tenantsTable');
tbody.innerHTML = '<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>';
try {
var res = await fetch('/api/admin/tenants', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) throw new Error('Error al cargar tenants (' + res.status + ')');
var data = await res.json();
var tenants = data.tenants || [];
if (tenants.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay tenants activos</td></tr>';
return;
}
// Load modules for each tenant
var modulesMap = {};
await Promise.all(tenants.map(async function(t) {
try {
var mres = await fetch('/api/admin/tenants/' + t.id + '/modules', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (mres.ok) {
modulesMap[t.id] = await mres.json();
} else {
modulesMap[t.id] = {};
}
} catch (e) {
modulesMap[t.id] = {};
}
}));
renderTenantsTable(tenants, modulesMap);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
}
}
function renderTenantsTable(tenants, modulesMap) {
var tbody = document.getElementById('tenantsTable');
tbody.innerHTML = tenants.map(function(t) {
var mods = modulesMap[t.id] || {};
function toggleBtn(tenantId, key, enabled) {
var label = enabled ? 'Activado' : 'Desactivado';
var cls = enabled ? 'btn-primary' : 'btn-secondary';
return '<button class="btn ' + cls + '" style="font-size:0.75rem; padding:3px 10px;" ' +
'onclick="toggleTenantModule(' + tenantId + ', \'' + key + '\', ' + enabled + ')">' + label + '</button>';
}
return '<tr>' +
'<td>' + t.id + '</td>' +
'<td>' + (t.name || '-') + '</td>' +
'<td>' + toggleBtn(t.id, 'whatsapp_enabled', !!mods.whatsapp_enabled) + '</td>' +
'<td>' + toggleBtn(t.id, 'marketplace_enabled', !!mods.marketplace_enabled) + '</td>' +
'<td>' + toggleBtn(t.id, 'meli_enabled', !!mods.meli_enabled) + '</td>' +
'<td><button class="btn btn-primary" style="font-size:0.75rem; padding:3px 10px;" onclick="loadTenants()">🔄 Recargar</button></td>' +
'</tr>';
}).join('');
}
async function toggleTenantModule(tenantId, key, currentValue) {
var token = localStorage.getItem('access_token');
var moduleNames = {
'whatsapp_enabled': 'WhatsApp',
'marketplace_enabled': 'Marketplace',
'meli_enabled': 'MercadoLibre'
};
var action = currentValue ? 'desactivar' : 'activar';
if (!confirm('¿Seguro que deseas ' + action + ' ' + moduleNames[key] + ' para el tenant #' + tenantId + '?')) return;
try {
var payload = {};
payload[key] = !currentValue;
var res = await fetch('/api/admin/tenants/' + tenantId + '/modules', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
});
if (!res.ok) {
var err = await res.json();
throw new Error(err.error || 'Error al actualizar módulo');
}
showAlert(moduleNames[key] + ' ' + (currentValue ? 'desactivado' : 'activado') + ' para tenant #' + tenantId);
loadTenants();
} catch (e) {
showAlert(e.message, 'error');
}
}

View File

@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
from config import DB_URL
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
from tenant_db import get_tenant_conn
from services.translations import translate_part_name, translate_category
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
@@ -4628,6 +4629,76 @@ def part_aftermarket(part_id):
session.close()
# ============================================================================
# Tenant Module Config Endpoints
# ============================================================================
MODULE_CONFIG_KEYS = [
'whatsapp_enabled',
'marketplace_enabled',
'meli_enabled',
]
@app.route('/api/admin/tenants')
def api_admin_tenants():
session = Session()
try:
rows = session.execute(text(
"SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id"
)).mappings().all()
return jsonify({'tenants': [dict(r) for r in rows]})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@app.route('/api/admin/tenants/<int:tenant_id>/modules')
def api_admin_tenant_modules(tenant_id):
try:
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
result = {}
for key in MODULE_CONFIG_KEYS:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
result[key] = (row[0] or '').lower() == 'true' if row else False
cur.close()
conn.close()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/admin/tenants/<int:tenant_id>/modules', methods=['PUT'])
def api_admin_tenant_modules_update(tenant_id):
data = request.get_json() or {}
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
for key, value in data.items():
if key not in MODULE_CONFIG_KEYS:
continue
cur.execute(
"""
INSERT INTO tenant_config (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(key, 'true' if value else 'false'),
)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============================================================================
# Static files from dashboard root (CSS/JS/HTML)
# ============================================================================

View File

@@ -58,3 +58,24 @@ def delete_tenant(tenant_id):
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["GET"])
@require_manager_auth
def get_tenant_modules(tenant_id):
try:
result = tenant_service.get_tenant_modules(tenant_id)
return jsonify({"data": result})
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["PUT"])
@require_manager_auth
def update_tenant_modules(tenant_id):
data = request.get_json() or {}
try:
result = tenant_service.update_tenant_modules(tenant_id, data)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -311,6 +311,55 @@ def get_tenant_login_url(subdomain):
return f"https://{subdomain}.{domain}/pos/login"
def get_tenant_modules(tenant_id):
"""Get enabled modules for a tenant from tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
modules = {}
for key in ["module_whatsapp", "module_marketplace", "module_meli"]:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True
return modules
finally:
cur.close()
conn.close()
def update_tenant_modules(tenant_id, modules):
"""Update enabled modules for a tenant in tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
key_map = {
"whatsapp": "module_whatsapp",
"marketplace": "module_marketplace",
"meli": "module_meli",
}
for field, key in key_map.items():
value = "true" if modules.get(field) else "false"
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
return {"success": True, "tenant_id": tenant_id, "modules": modules}
finally:
cur.close()
conn.close()
def get_dashboard_stats():
"""Global stats for the manager dashboard."""
conn = get_master_conn()

View File

@@ -661,3 +661,42 @@ body {
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
.stats-grid { grid-template-columns: 1fr; }
}
/* Toggle switch for modules modal */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 24px;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--success);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}

View File

@@ -188,6 +188,7 @@ async function loadDemos() {
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${d.id}, '${escapeHtml(d.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -254,6 +255,7 @@ async function loadTenants(withStats = false) {
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
<td>${formatDate(t.created_at)}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${t.id}, '${escapeHtml(t.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -475,5 +477,59 @@ function copyText(text) {
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
}
// ─── Modules ───────────────────────────────────────────────────────────────
let currentModulesTenantId = null;
async function openModulesModal(tenantId, name) {
currentModulesTenantId = tenantId;
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
document.getElementById("modules-modal").style.display = "flex";
// Load current state
const res = await api(`/api/tenants/${tenantId}/modules`);
if (res && res.status === 200) {
const m = res.data.data;
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
document.getElementById("mod-meli").checked = m.meli !== false;
} else {
toast("Error al cargar módulos", "error");
}
}
function closeModulesModal() {
document.getElementById("modules-modal").style.display = "none";
currentModulesTenantId = null;
}
async function saveModules() {
if (!currentModulesTenantId) return;
const btn = document.getElementById("modules-save-btn");
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Guardando...`;
btn.disabled = true;
const payload = {
whatsapp: document.getElementById("mod-whatsapp").checked,
marketplace: document.getElementById("mod-marketplace").checked,
meli: document.getElementById("mod-meli").checked,
};
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
method: "PUT",
body: payload
});
if (res && res.status === 200) {
toast("Módulos actualizados", "success");
closeModulesModal();
} else {
toast(res?.data?.error || "Error al guardar", "error");
}
btn.innerHTML = originalText;
btn.disabled = false;
}
// ─── Init ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuth);

View File

@@ -316,6 +316,53 @@
</div>
</div>
<!-- Modules Modal -->
<div id="modules-modal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="closeModulesModal()"></div>
<div class="modal-content" style="max-width:480px;">
<div class="modal-header">
<h3 id="modules-modal-title">Módulos del Tenant</h3>
<button class="btn-icon" onclick="closeModulesModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">WhatsApp</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de WhatsApp Bridge</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-whatsapp">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">Marketplace</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Marketplace interno</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-marketplace">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
<div>
<div style="font-weight:600;color:var(--text);">MercadoLibre</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de MercadoLibre</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-meli">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModulesModal()">Cancelar</button>
<button class="btn btn-primary" id="modules-save-btn" onclick="saveModules()">Guardar</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast-container"></div>

View File

@@ -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():

View File

@@ -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

View File

@@ -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})

View File

@@ -0,0 +1,100 @@
-- ============================================================
-- v3.5 WhatsApp State Machine
-- Reorganización del chatbot de AI libre a flujo estructurado
-- ============================================================
-- 1. Extender whatsapp_sessions con estado y contexto
-- ---------------------------------------------------
ALTER TABLE whatsapp_sessions
ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle',
ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id),
ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id),
ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- Índices para lookups rápidos de sesión
CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
-- ------------------------------------------------
CREATE TABLE IF NOT EXISTS wa_customer_links (
phone VARCHAR(50) PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
CREATE TRIGGER trg_wa_link_updated
BEFORE UPDATE ON wa_customer_links
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
id SERIAL PRIMARY KEY,
phone VARCHAR(50) NOT NULL,
customer_id INTEGER REFERENCES customers(id),
description TEXT NOT NULL,
offered_parts JSONB DEFAULT '[]',
status VARCHAR(20) DEFAULT 'pending',
resolved_part_id INTEGER REFERENCES inventory(id),
resolution_sale_id INTEGER REFERENCES sales(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
-- 4. Tabla de configuración de envío por sucursal
-- ------------------------------------------------
CREATE TABLE IF NOT EXISTS branch_delivery_config (
id SERIAL PRIMARY KEY,
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
is_enabled BOOLEAN DEFAULT FALSE,
delivery_fee NUMERIC(12,2) DEFAULT 0,
free_delivery_threshold NUMERIC(12,2) DEFAULT NULL,
coverage_radius_km INTEGER DEFAULT NULL,
delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 5. Agregar push_name a whatsapp_messages (schema drift existente)
-- ------------------------------------------------------------------
ALTER TABLE whatsapp_messages
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
-- 6. Migrar datos existentes: vincular por teléfono
-- --------------------------------------------------
-- Intentar vincular sesiones WA existentes con customers por teléfono
INSERT INTO wa_customer_links (phone, customer_id)
SELECT ws.phone, c.id
FROM whatsapp_sessions ws
JOIN customers c ON c.phone = ws.phone
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
ON CONFLICT (phone) DO NOTHING;
-- Actualizar customer_id en whatsapp_sessions desde el vínculo
UPDATE whatsapp_sessions ws
SET customer_id = wcl.customer_id
FROM wa_customer_links wcl
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;

View File

@@ -440,6 +440,13 @@ def process_sale(conn, sale_data):
except Exception:
pass # Savings errors never block sales
# WhatsApp learning hook (non-blocking)
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale_id, customer_id, conn)
except Exception:
pass # Learning errors never block sales
return {
'id': sale_id,
'branch_id': branch_id,

140
pos/services/wa_customer.py Normal file
View File

@@ -0,0 +1,140 @@
"""
WhatsApp Customer Service — identificación y vinculación de clientes.
Funciones para buscar, crear y vincular clientes desde el flujo de WhatsApp.
"""
import re
def find_customer_by_phone(phone, tenant_conn):
"""Buscar cliente por número de teléfono exacto o parcial."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
# Limpiar phone de prefijos internacionales para búsqueda flexible
clean = phone.replace('+52', '').replace('52', '').lstrip('1')
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE phone = %s OR phone LIKE %s OR phone LIKE %s
LIMIT 5
""", (phone, f'%{clean}', f'%{clean[-10:]}' if len(clean) >= 10 else f'%{clean}'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def find_customer_by_name(name, tenant_conn):
"""Buscar cliente por nombre (ILIKE)."""
if not tenant_conn or not name:
return []
cur = tenant_conn.cursor()
# Buscar por nombre completo o primer palabra
first_word = name.split()[0] if name else name
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE name ILIKE %s OR name ILIKE %s
LIMIT 5
""", (f'%{name}%', f'%{first_word}%'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def search_customers(query, tenant_conn):
"""Buscar por teléfono o nombre."""
if not tenant_conn or not query:
return []
# Detectar si es número de teléfono
digits = re.sub(r'\D', '', query)
if len(digits) >= 7:
by_phone = find_customer_by_phone(digits, tenant_conn)
if by_phone:
return by_phone
return find_customer_by_name(query, tenant_conn)
def get_customer_by_id(tenant_conn, customer_id):
"""Obtener cliente por ID."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, name, phone, address, rfc, vehicle_info
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
cur.close()
if row:
return {
'id': row[0], 'name': row[1], 'phone': row[2],
'address': row[3], 'rfc': row[4], 'vehicle_info': row[5]
}
return None
def create_customer(tenant_conn, phone, name, email=None, address=None, rfc=None):
"""Crear cliente nuevo desde WhatsApp."""
if not tenant_conn:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO customers (name, phone, email, address, rfc, is_active, created_at)
VALUES (%s, %s, %s, %s, %s, TRUE, NOW())
RETURNING id
""", (name, phone, email, address, rfc))
cid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return cid
def link_wa_customer(phone, customer_id, tenant_conn):
"""Vincular número WA a cliente permanentemente."""
if not tenant_conn or not phone or not customer_id:
return
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_customer_links (phone, customer_id, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = NOW()
""", (phone, customer_id))
tenant_conn.commit()
cur.close()
def get_linked_customer(phone, tenant_conn):
"""Obtener customer_id vinculado a un número WA."""
if not tenant_conn or not phone:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT customer_id FROM wa_customer_links WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def get_customer_address(tenant_conn, customer_id):
"""Obtener dirección del cliente."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT address FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else None
def update_customer_address(tenant_conn, customer_id, address):
"""Actualizar dirección del cliente."""
if not tenant_conn or not customer_id or not address:
return
cur = tenant_conn.cursor()
cur.execute(
"UPDATE customers SET address = %s WHERE id = %s",
(address, customer_id)
)
tenant_conn.commit()
cur.close()

127
pos/services/wa_learning.py Normal file
View File

@@ -0,0 +1,127 @@
"""
WhatsApp Learning Service — ruta de aprendizaje para piezas no resueltas.
Registra sesiones donde el bot no pudo identificar una pieza, y las resuelve
asíncronamente cuando el cliente realiza una compra futura.
"""
import json
def register_unresolved_search(phone, customer_id, description, offered_parts, tenant_conn):
"""Registrar una sesión no resuelta para aprendizaje futuro."""
if not tenant_conn or not phone or not description:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_learning_sessions (phone, customer_id, description, offered_parts, status, created_at)
VALUES (%s, %s, %s, %s, 'pending', NOW())
RETURNING id
""", (phone, customer_id, description, json.dumps(offered_parts or [])))
sid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return sid
def find_pending_sessions(phone, tenant_conn):
"""Buscar sesiones pendientes de aprendizaje para un número WA."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE phone = %s AND status = 'pending'
ORDER BY created_at DESC
""", (phone,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'description': r[1], 'offered_parts': r[2], 'created_at': str(r[3])} for r in rows]
def find_pending_sessions_by_customer(customer_id, tenant_conn):
"""Buscar sesiones pendientes por customer_id."""
if not tenant_conn or not customer_id:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, phone, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE customer_id = %s AND status = 'pending'
ORDER BY created_at DESC
""", (customer_id,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'phone': r[1], 'description': r[2], 'offered_parts': r[3], 'created_at': str(r[4])} for r in rows]
def resolve_session(session_id, resolved_part_id, sale_id, tenant_conn):
"""Marcar sesión como resuelta con la pieza comprada."""
if not tenant_conn or not session_id:
return
cur = tenant_conn.cursor()
cur.execute("""
UPDATE wa_learning_sessions
SET status = 'learned', resolved_part_id = %s, resolution_sale_id = %s, resolved_at = NOW()
WHERE id = %s
""", (resolved_part_id, sale_id, session_id))
tenant_conn.commit()
cur.close()
def get_learning_pairs_for_training(tenant_conn, limit=100):
"""Obtener pares (descripción del cliente → pieza real) para entrenamiento."""
if not tenant_conn:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT l.description, i.name, i.part_number, i.brand
FROM wa_learning_sessions l
JOIN inventory i ON i.id = l.resolved_part_id
WHERE l.status = 'learned' AND l.resolved_at > NOW() - INTERVAL '90 days'
ORDER BY l.resolved_at DESC
LIMIT %s
""", (limit,))
rows = cur.fetchall()
cur.close()
return [{'description': r[0], 'part_name': r[1], 'part_number': r[2], 'brand': r[3]} for r in rows]
def check_learning_resolution(sale_id, customer_id, tenant_conn):
"""
Hook para llamar después de completar una venta.
Verifica si esta venta resuelve una sesión de aprendizaje pendiente.
"""
if not tenant_conn or not customer_id:
return
sessions = find_pending_sessions_by_customer(customer_id, tenant_conn)
if not sessions:
return
# Obtener items de esta venta
cur = tenant_conn.cursor()
cur.execute("""
SELECT si.inventory_id, i.name, i.part_number
FROM sale_items si
JOIN inventory i ON i.id = si.inventory_id
WHERE si.sale_id = %s
""", (sale_id,))
sale_items = cur.fetchall()
cur.close()
if not sale_items:
return
# Matching heurístico
for sess in sessions:
desc_words = set(sess['description'].lower().split())
for inv_id, item_name, part_number in sale_items:
item_words = set(item_name.lower().split())
# Intersección de palabras significativas
common = desc_words & item_words - {'de', 'la', 'el', 'para', 'un', 'una', 'con', 'y', 'o', 'en', 'al', 'del', 'los', 'las'}
if len(common) >= 2:
resolve_session(sess['id'], inv_id, sale_id, tenant_conn)
print(f"[WA-LEARN] Resolved session {sess['id']} with sale {sale_id}, item {inv_id}")
break

View File

@@ -105,7 +105,7 @@ def confirm_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
clear_last_shown(tenant_conn, phone)
return qid
@@ -342,7 +342,7 @@ def clear_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
clear_last_shown(tenant_conn, phone)
return qid

File diff suppressed because it is too large Load Diff

View File

@@ -88,9 +88,19 @@ def process_incoming(webhook_data):
key = data.get('key', {})
message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid
# remoteJid can be phone@s.whatsapp.net or LID:instance@lid
remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
# Strip JID suffixes and LID instance suffix (:12)
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
# DEBUG
import json
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
# senderPn contains the real phone number when remoteJid is a privacy LID
sender_pn = key.get('senderPn', '')
if sender_pn:
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
# The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version).
@@ -122,6 +132,7 @@ def process_incoming(webhook_data):
return {
'phone': phone,
'jid': remote_jid,
'sender_pn': sender_pn,
'text': text,
'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''),

View File

@@ -180,4 +180,18 @@
permissions: payload.permissions || []
};
// ─── Preload enabled modules for sidebar filtering ───
try {
fetch('/pos/api/config/modules', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
}).then(function(data) {
if (data) {
localStorage.setItem('pos_modules', JSON.stringify(data));
window.POS_USER.modules = data;
}
}).catch(function() {});
} catch(e) {}
})();

View File

@@ -689,6 +689,53 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Modules / Integrations
// -------------------------------------------------------------------------
async function loadModules() {
try {
var res = await fetch(API + '/modules', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbWa = document.getElementById('cfg-module-whatsapp');
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false;
localStorage.setItem('pos_modules', JSON.stringify(data));
} catch (e) {
console.error('Config.loadModules:', e);
}
}
async function saveModules() {
var btn = event.target;
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var data = {
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: document.getElementById('cfg-module-meli').checked,
};
var res = await fetch(API + '/modules', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -744,6 +791,7 @@ const Config = (() => {
loadCurrency();
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
}
document.addEventListener('DOMContentLoaded', init);
@@ -753,6 +801,7 @@ const Config = (() => {
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal
};
// Register Cmd+K items

View File

@@ -17,6 +17,16 @@
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {};
try {
modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
} catch(e) { modules = {}; }
function moduleEnabled(key) {
// Default to true if not configured yet
return modules[key] !== false;
}
var navSections = [
{ label: _t('nav_main'), items: [
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
@@ -28,14 +38,14 @@
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
{ name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
{ name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
]},
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean)},
{ label: _t('nav_system'), items: [
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
]},

View File

@@ -179,7 +179,7 @@ function cacheFirst(request) {
return caches.match(request).then(function (cached) {
if (cached) {
fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, response);
});
@@ -188,7 +188,7 @@ function cacheFirst(request) {
return cached;
}
return fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);
@@ -201,7 +201,7 @@ function cacheFirst(request) {
function networkFirst(request) {
return fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);

View File

@@ -251,7 +251,58 @@
</div>
<!-- ===============================================================
SECTION 3: USUARIOS Y PERMISOS
SECTION 3: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<div>
<div class="settings-section__title">Módulos e Integraciones</div>
<div class="settings-section__desc">Activa o desactiva funcionalidades del menú para este tenant</div>
</div>
</div>
<div class="settings-card">
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">WhatsApp</span>
<span class="toggle-row__desc">Mostrar el menú de WhatsApp Bridge y chat</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-whatsapp" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Marketplace</span>
<span class="toggle-row__desc">Mostrar el menú de Marketplace interno</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-marketplace" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">MercadoLibre</span>
<span class="toggle-row__desc">Mostrar el menú de integración con MercadoLibre</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-meli" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS
=============================================================== -->
<div class="settings-section">
<div class="settings-section__header">

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"type": "commonjs",
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"@whiskeysockets/baileys": "^6.7.23",
"express": "^4.18.2",
"qrcode": "^1.5.3",
"pino": "^8.16.2"

View File

@@ -28,8 +28,9 @@ const sendQueue = [];
let queueTimer = null;
let connectWatchdog = null;
let staleWatchdog = null;
const WATCHDOG_MS = 90000;
const STALE_MS = 90000;
let queueFlushInterval = null;
const WATCHDOG_MS = 300000; // 5 minutos para dar tiempo al QR scanning
const STALE_MS = 1800000; // 30 minutos (keepalive ya maneja pings, no forzamos reconexión por inactividad)
let lastActivity = Date.now();
function updateActivity() {
@@ -67,19 +68,14 @@ function flushSendQueue() {
function clearWatchdog() {
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
if (queueFlushInterval) { clearInterval(queueFlushInterval); queueFlushInterval = null; }
}
function scheduleStaleWatchdog() {
if (staleWatchdog) clearInterval(staleWatchdog);
staleWatchdog = setInterval(() => {
if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) {
console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`);
try { sock?.ws?.close(); } catch (e) {}
sock = null;
connectionState = 'disconnected';
setTimeout(connectWhatsApp, 30000);
}
}, 30000);
// ELIMINADO: el stale watchdog forzaba reconexión cada 90s sin mensajes,
// lo cual destruía la sesión de Baileys y provocaba 440 + limpieza de auth.
// Baileys ya envía keepalive cada 15s (keepAliveIntervalMs). No es necesario.
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
}
function scheduleWatchdog() {
@@ -118,7 +114,7 @@ async function connectWhatsApp() {
auth: state,
logger,
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
browser: ['Ubuntu', 'Chrome', '120.0.0.0'],
browser: ['Chrome', 'Windows', '124.0.0.0'],
defaultQueryTimeoutMs: 60000,
keepAliveIntervalMs: 15000,
markOnlineOnConnect: false,
@@ -157,23 +153,24 @@ async function connectWhatsApp() {
}
if (reason === 440) {
// 440 = conflict/replaced. The session data is permanently invalid.
// Clean auth immediately and wait 5 min so WhatsApp forgets the old session.
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`);
clearAuthState();
sock = null;
// 440 = conflict/replaced. WhatsApp reemplazó esta sesión (puede ser
// por reconexión muy rápida, o porque el teléfono abrió otra sesión).
// NUNCA limpiamos auth automáticamente — solo esperamos más tiempo.
retry440Count++;
const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min
const delay = retry440Count >= 3 ? 600000 : 120000; // 2min → 10min
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — reconnecting in ${delay/1000}s with existing creds (attempt ${retry440Count})`);
sock = null;
setTimeout(connectWhatsApp, delay);
return;
}
if (reason === 515) {
// 515 = stream error, often precedes 440. Treat same as 440.
console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`);
clearAuthState();
// 515 = stream error / restart required. WhatsApp sends this after
// successful pairing to force a reconnect with the new credentials.
// DO NOT clear auth — the credentials were just saved by creds.update.
console.log(`[Tenant ${TENANT_ID}] 515 Restart required — reconnecting in 5s with saved creds`);
sock = null;
setTimeout(connectWhatsApp, 300000);
setTimeout(connectWhatsApp, 5000);
return;
}
@@ -185,19 +182,11 @@ async function connectWhatsApp() {
}
if (reason === 408) {
// 408 during init queries usually means the server is overloaded
// or our auth is partially invalid. Clear auth if this happens repeatedly.
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`);
// 408 during init queries = rate-limit o auth parcialmente inválido.
// No limpiamos auth automáticamente; esperamos más tiempo.
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 120s`);
sock = null;
retry440Count++;
if (retry440Count >= 5) {
console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`);
clearAuthState();
retry440Count = 0;
setTimeout(connectWhatsApp, 300000);
return;
}
setTimeout(connectWhatsApp, 60000);
setTimeout(connectWhatsApp, 120000);
return;
}
@@ -208,20 +197,33 @@ async function connectWhatsApp() {
}
if (connection === 'open') {
// Race-condition guard: if sock was nulled by a concurrent disconnect,
// ignore this stale 'open' event.
if (!sock) {
console.log(`[Tenant ${TENANT_ID}] Ignoring stale 'open' event (sock is null)`);
return;
}
clearWatchdog();
connectionState = 'open';
qrCode = null;
retry440Count = 0;
updateActivity();
scheduleStaleWatchdog();
// Stale watchdog eliminado — Baileys ya mantiene keepalive.
console.log(`[Tenant ${TENANT_ID}] Connected!`);
flushSendQueue();
if (!queueFlushInterval) {
queueFlushInterval = setInterval(() => {
if (connectionState === 'open') flushSendQueue();
}, 5000);
}
}
});
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (msg.key.fromMe) continue;
// Skip system/receipt messages with no meaningful content
if (!msg.message || Object.keys(msg.message).length === 0) continue;
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
const message = msg.message || {};
@@ -289,7 +291,8 @@ async function connectWhatsApp() {
media_ptt,
latitude,
longitude,
push_name: msg.pushName || ''
push_name: msg.pushName || '',
sender_pn: msg.key?.senderPn || ''
}
}),
signal: controller.signal
@@ -332,21 +335,22 @@ app.post('/send', async (req, res) => {
return res.status(400).json({ error: 'phone and message required' });
}
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
console.log(`[Tenant ${TENANT_ID}] /send called for ${jid}. state=${connectionState}, sock=${!!sock}`);
if (connectionState !== 'open' || !sock) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
return res.status(202).json({ queued: true, state: connectionState });
if (connectionState === 'open' && sock) {
try {
const r = await sock.sendMessage(jid, { text: message });
res.json({ success: true, id: r.key.id });
return;
} catch (e) {
console.log(`[Tenant ${TENANT_ID}] Send failed, will queue:`, e.message);
// fall through to queue
}
}
try {
const r = await sock.sendMessage(jid, { text: message });
res.json({ success: true, id: r.key.id });
} catch (e) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message);
res.status(202).json({ queued: true, error: e.message });
}
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
res.status(202).json({ queued: true, state: connectionState });
});
app.post('/send-image', async (req, res) => {
const { phone, caption, base64 } = req.body;