Files
whatsapp/services/BotService.php
T

1648 lines
76 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Servicio Bot - Manejo de conversaciones y menús
* Fecha: 13 de noviembre de 2025
*/
class BotService {
private $db;
private $whatsappService;
public function __construct() {
$this->db = Database::getInstance();
$this->whatsappService = new WhatsAppService();
}
/**
* Procesar mensaje entrante
*/
public function processMessage($user, $messageText, $messageType = 'text') {
// Solo procesar mensajes de texto
if ($messageType !== 'text') {
return;
}
$messageText = trim($messageText);
$phoneNumber = $user['phone_number'];
$userId = $user['id'] ?? null;
// ── Advisory lock por usuario ─────────────────────────────────────────
// Serializar el procesamiento para el mismo usuario y evitar race conditions
// (WhatsApp puede entregar el mismo webhook dos veces en paralelo).
$lockAcquired = false;
if ($userId) {
$lockKey = 'bot_user_' . $userId;
$lockRow = $this->db->fetch("SELECT GET_LOCK(:k, 10) as ok", ['k' => $lockKey]);
$lockAcquired = !empty($lockRow['ok']);
if (!$lockAcquired) {
error_log("[BotService] processMessage - lock timeout para user_id={$userId}, ignorando llamada concurrente");
return;
}
// Recargar datos frescos del usuario después de adquirir el lock
$fresh = $this->db->fetch("SELECT * FROM users WHERE id = :id", ['id' => $userId]);
if ($fresh) $user = $fresh;
}
try {
// Comprobar en la base de datos el estado más reciente de 'in_service' para evitar condiciones de carrera
try {
$fresh = $this->db->fetch("SELECT in_service, in_service_by FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
if (!empty($fresh['in_service'])) {
try {
$userId = $user['id'] ?? 'unknown';
error_log("[BotService] processMessage - user_id={$userId} is currently in_service (DB), skipping bot processing");
} catch (Throwable $t) {
// ignore logging errors
}
return;
}
// actualizar el array $user con valores frescos por si se usan más adelante
$user['in_service'] = !empty($fresh['in_service']);
$user['in_service_by'] = $fresh['in_service_by'] ?? null;
} catch (Exception $e) {
error_log('[BotService] processMessage - failed to refresh in_service: ' . $e->getMessage());
// En caso de fallo al consultar, continuar con lógica previa (no bloquear)
}
// DEBUG: log basic context
try {
$userId = $user['id'] ?? 'unknown';
error_log("[BotService] processMessage start - user_id={$userId} phone={$phoneNumber} type={$messageType} message='" . substr($messageText,0,200) . "' current_menu_id=" . ($user['current_menu_id'] ?? 'null') . " on_hold=" . ($user['on_hold'] ?? '0') . " advisor_requested=" . ($user['advisor_requested'] ?? '0') . " bot_paused_until=" . ($user['bot_paused_until'] ?? 'null') . " bot_enabled=" . (isset($user['bot_enabled']) ? $user['bot_enabled'] : 'null'));
} catch (Throwable $t) {
// ignore logging errors
}
// Si ha pasado suficiente tiempo (6 horas) desde la última actividad, reiniciar la conversación y permitir re-enviar el welcome
try {
if (!empty($user['id'])) {
$this->maybeResetConversationAfterIdle($user['id']);
}
} catch (Exception $e) {
error_log('[BotService] maybeResetConversationAfterIdle threw: ' . $e->getMessage());
}
// ── Flujo de Términos y Condiciones ──────────────────────────────────
// 1. Si el usuario está esperando respuesta de términos, procesarla primero.
if (!empty($user['terms_pending'])) {
if ($this->processTermsResponse($user, $phoneNumber, $messageText)) {
return;
}
}
// 2. Si el usuario necesita aceptar (nuevo, expirado, o versión nueva con forzar_reenvio).
if ($this->needsTermsAcceptance($user)) {
$this->sendTermsMessage($user, $phoneNumber);
return;
}
// ─────────────────────────────────────────────────────────────────────
// Verificar si es un nuevo usuario (enviar mensaje de bienvenida)
// Solo se envía si ya aceptó los términos, o si no hay términos activos configurados.
$termsRequired = $this->getActiveTermsVersion() !== null;
$termsAccepted = !empty($user['terms_accepted_at']);
if ($this->isNewUser($user['id']) && (!$termsRequired || $termsAccepted)) {
$this->sendWelcomeMessage($phoneNumber);
return;
}
// Si el usuario escribe 'enviado' o 'enviados' en cualquier momento, enviar confirmación de recepción
try {
// Support masculino/femenino, singular/plural: enviado, enviada, enviados, enviadas
if (preg_match('/\b(enviad[oa]s?)\b/iu', $messageText)) {
// Cuando el usuario confirma que envió la documentación, notificarle y transferir a un asesor.
try {
// Usar requestAdvisor para aplicar advisor_requested, pausar el bot y enviar notificación al usuario
$this->requestAdvisor($phoneNumber, 3);
// Registrar mensaje de sistema para trazabilidad (no altera flujo de notificación)
$sys = [
'system' => 'attention',
'type' => 'user_sent_documents',
'text' => 'El usuario indicó que ha enviado documentos o información; requiere atención.'
];
$this->db->insert('conversations', [
'user_id' => $user['id'],
'direction' => 'incoming',
'message_type' => 'text',
'content' => json_encode($sys),
'status' => 'received',
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('[BotService] failed to request advisor on envio: ' . $e->getMessage());
}
error_log("[BotService] processMessage - requested advisor for user {$user['id']} (sent notification to user)");
return;
}
} catch (Throwable $t) {
// ignore regex errors
}
// Procesar comandos especiales
if ($this->processSpecialCommands($phoneNumber, $messageText)) {
return;
}
// Si el usuario está en espera por un asesor, y el periodo no expiró, no procesar.
if (!empty($user['on_hold']) && $user['on_hold']) {
$now = time();
$until = null;
if (!empty($user['bot_paused_until'])) {
$until = strtotime($user['bot_paused_until']);
}
// Si hay un tiempo de expiración y aún no pasó, notificar y retornar
if ($until && $until > $now) {
try {
$this->whatsappService->sendTextMessage($phoneNumber, "⚠️ Se le ha puesto en espera por un asesor. Estará en espera hasta " . date('g:i a', $until) . ". Si no hay respuesta, podrá usar *MENU*." );
} catch (Exception $e) {
// ignore
}
error_log("[BotService] processMessage - returning early because on_hold active for user {$user['id']}");
return;
} else {
// El hold expiró, liberar y continuar
$this->releaseHold($phoneNumber);
}
}
// Si existe una solicitud de asesor (por parte del usuario) y aún no ha respondido un asesor, respetar la espera
if (!empty($user['advisor_requested']) && $user['advisor_requested']) {
$now = time();
$until = !empty($user['bot_paused_until']) ? strtotime($user['bot_paused_until']) : null;
if ($until && $until > $now) {
// Permitir *navegación explícita* durante la espera: MENU, ATRÁS, números (opciones)
$normalized = mb_strtolower(trim($messageText));
$isNav = in_array($normalized, ['menu','menú','inicio','atras','atrás','volver','back'], true) || is_numeric($messageText);
// No enviar notificación automática al usuario cuando advisor_requested está activa.
// Mantener navegación explícita permitida (MENU/ATRÁS/números); no enviar mensajes proactivos.
if ($isNav) {
try { error_log("[BotService] processMessage - advisor_requested active but navigation input allowed for user {$user['id']} message='{$messageText}'"); } catch (Throwable $t) {}
// Manejar la navegación inmediatamente (no esperar al flujo normal)
$normalized = mb_strtolower(trim($messageText));
// MENU
if (in_array($normalized, ['menu','menú','inicio'], true)) {
$this->showMainMenu($phoneNumber);
return;
}
// ATRÁS
if (in_array($normalized, ['atras','atrás','volver','back'], true)) {
// Si hay menú actual, intentar ir al padre, si no, usar historial
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$currentMenuId = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$currentMenuId = null;
}
if (!empty($currentMenuId)) {
$this->goToParentMenu($phoneNumber, $currentMenuId);
} else {
$this->goToPreviousMenu($phoneNumber);
}
return;
}
// Si es número, intentar procesarlo contra current_menu_id o historial
if (is_numeric(trim($messageText))) {
try {
$stateSvc2 = new ConversationStateService();
$resolvedMenuId2 = $stateSvc2->getCurrentMenuId($phoneNumber);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
if (empty($resolvedMenuId2)) {
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$resolvedMenuId2 = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
// Si no hay menú activo, intentar con historial
if (empty($resolvedMenuId2)) {
try {
$hist = (array)$stateSvc2->getStateData($phoneNumber, 'menu_history');
if (!empty($hist)) $resolvedMenuId2 = end($hist);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
if (!empty($resolvedMenuId2)) {
// Asegurar que la BD refleje el menu activo para que processMenuSelection lo resuelva correctamente
try {
$this->db->update('users', ['current_menu_id' => $resolvedMenuId2, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
try {
$stateSvc2->setCurrentMenu($phoneNumber, (int)$resolvedMenuId2);
} catch (Exception $e) {
// ignore
}
} catch (Exception $e) {
error_log('[BotService] failed to set current_menu_id before processing numeric selection: ' . $e->getMessage());
}
$user['current_menu_id'] = $resolvedMenuId2;
$this->processMenuSelection($user, $messageText);
return;
}
}
// Si llegamos aquí no pudimos resolver navegación; permitir que el flujo normal lo trate
} else {
error_log("[BotService] processMessage - returning early because advisor_requested active for user {$user['id']}");
return;
}
} else {
// expiró la espera: limpiar flag
try {
$this->db->update('users', ['advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
} catch (Exception $e) {
error_log('Failed to clear advisor_requested on expiry: ' . $e->getMessage());
}
}
}
// Si el bot está pausado por bot_paused_until, no responder automáticamente
if (!empty($user['bot_paused_until']) && strtotime($user['bot_paused_until']) > time()) {
// Allow explicit navigation commands and numeric menu selection during pause (better UX)
$normalized = mb_strtolower(trim($messageText));
$isNav = in_array($normalized, ['menu','menú','inicio','atras','atrás','volver','back'], true) || is_numeric(trim($messageText));
if ($isNav) {
try { error_log("[BotService] processMessage - bot_paused_until active but allowing navigation input for phone={$phoneNumber} message='{$messageText}'"); } catch (Throwable $t) {}
// Si es un número, procesarlo inmediatamente como selección de menú
if (is_numeric(trim($messageText))) {
try {
$stateSvc2 = new ConversationStateService();
$resolvedMenuId2 = $stateSvc2->getCurrentMenuId($phoneNumber);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
if (empty($resolvedMenuId2)) {
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$resolvedMenuId2 = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
// Si aún no hay menú activo, intentar con historial
if (empty($resolvedMenuId2)) {
try {
$hist = (array)$stateSvc2->getStateData($phoneNumber, 'menu_history');
if (!empty($hist)) $resolvedMenuId2 = end($hist);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
if (!empty($resolvedMenuId2)) {
// Asegurar que la BD refleje el menu activo para que processMenuSelection lo resuelva correctamente
try {
$this->db->update('users', ['current_menu_id' => $resolvedMenuId2, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
try {
$stateSvc2->setCurrentMenu($phoneNumber, (int)$resolvedMenuId2);
} catch (Exception $e) {
// ignore
}
} catch (Exception $e) {
error_log('[BotService] failed to set current_menu_id before processing numeric selection during pause: ' . $e->getMessage());
}
$user['current_menu_id'] = $resolvedMenuId2;
$this->processMenuSelection($user, $messageText);
return;
}
}
// proceed to let navigation/menus be processed
} else {
return;
}
}
// Si el usuario ha desactivado el bot manualmente (toggle), evitar respuestas automáticas
// pero permitir comandos explícitos (MENU, ATRÁS) y selección numérica dentro de menús.
$botDisabled = (isset($user['bot_enabled']) && !$user['bot_enabled']);
// Verificar si el asesor ya respondió después del último mensaje del usuario.
// En ese caso NO enviaremos respuestas automáticas, pero sí permitiremos
// que el usuario interactúe con menús manualmente.
$lastIncoming = $this->db->fetch("SELECT created_at FROM conversations WHERE user_id = :uid AND direction = 'incoming' ORDER BY created_at DESC LIMIT 1", ['uid' => $user['id']]);
$lastOutgoing = $this->db->fetch("SELECT created_at FROM conversations WHERE user_id = :uid AND direction = 'outgoing' ORDER BY created_at DESC LIMIT 1", ['uid' => $user['id']]);
$advisorReplied = false;
if ($lastOutgoing && $lastIncoming && strtotime($lastOutgoing['created_at']) >= strtotime($lastIncoming['created_at'])) {
// El asesor ya respondió, marcar flag pero no retornar (permitir menús y comandos explícitos)
$advisorReplied = true;
error_log("[BotService] processMessage - advisorReplied=true for user {$user['id']}");
try { error_log("[BotService] processMessage - lastOutgoing={$lastOutgoing['created_at']} lastIncoming={$lastIncoming['created_at']}"); } catch (Throwable $t) {}
} else {
try { error_log("[BotService] processMessage - advisorReplied=false lastOutgoing=" . ($lastOutgoing['created_at'] ?? 'null') . " lastIncoming=" . ($lastIncoming['created_at'] ?? 'null')) ; } catch (Throwable $t) {}
}
// Resolver current_menu_id fresco (evitar usar $user desactualizado que viene del webhook)
$resolvedMenuId = null;
try {
$stateSvc = new ConversationStateService();
$resolvedMenuId = $stateSvc->getCurrentMenuId($phoneNumber);
} catch (Exception $e) {
// ignore
}
if (empty($resolvedMenuId)) {
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$resolvedMenuId = $cur ? $cur['current_menu_id'] : ($user['current_menu_id'] ?? null);
} catch (Exception $e) {
$resolvedMenuId = $user['current_menu_id'] ?? null;
}
}
// Actualizar el array $user para que funciones posteriores lo usen si es necesario
$user['current_menu_id'] = $resolvedMenuId;
try { error_log("[BotService] processMessage - resolved current_menu_id={$resolvedMenuId} for user {$user['id']} phone={$phoneNumber}"); } catch (Throwable $t) {}
// Verificar si el usuario está en un menú
if (!empty($resolvedMenuId)) {
$this->processMenuSelection($user, $messageText);
return;
}
// Si no hay menú actual pero el usuario envía un número, intentar interpretar el número
// contra el último menú en el historial (por ejemplo: user vio opciones, eligió end y luego envía '6' para volver)
if (is_numeric(trim($messageText))) {
try {
$stateSvc = new ConversationStateService();
$history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history');
if (!empty($history)) {
$lastMenu = end($history);
try { error_log("[BotService] interpreting numeric input '{$messageText}' against lastMenu={$lastMenu} for phone={$phoneNumber}"); } catch (Throwable $t) {}
// simular que estamos en ese menú para procesar la selección
$user['current_menu_id'] = $lastMenu;
$this->processMenuSelection($user, $messageText);
return;
}
} catch (Exception $e) {
// ignore and fallthrough to default
error_log('[BotService] failed to check menu_history: ' . $e->getMessage());
}
}
// No usar las "respuestas rápidas" (autoresponses) automáticamente desde el bot.
// Estas respuestas solo se mostrarán en la UI del chat como "respuestas rápidas".
// Si un asesor ya respondió recientemente, no enviar respuestas automáticas.
if (empty($advisorReplied)) {
if (empty($botDisabled)) {
$this->sendDefaultNoMatch($phoneNumber);
} else {
try { error_log("[BotService] processMessage - bot disabled, skipping default no-match for user {$user['id']}"); } catch (Throwable $t) {}
}
} else {
// Opcional: informar que el asesor ya respondió y no podemos enviar automáticas
try {
//$this->whatsappService->sendTextMessage($phoneNumber, "🔕 Un asesor ya ha intervenido en la conversación, por favor espera su respuesta o utiliza *MENU* para ver opciones.");
} catch (Exception $e) {
// ignore
}
}
} finally {
// Liberar advisory lock si fue adquirido
if ($lockAcquired && isset($lockKey)) {
try { $this->db->query("DO RELEASE_LOCK(:k)", ['k' => $lockKey]); } catch (Exception $e) {}
}
}
}
/**
* Si la última actividad del usuario fue hace >= 6 horas, reinicia el estado de la conversación
* para que el usuario vuelva a recibir la bienvenida y empiece desde cero. Esto incluye limpiar
* menús, flags de espera/asesor, y `welcome_sent_at`.
*/
private function maybeResetConversationAfterIdle($userId) {
try {
$sixHours = 6 * 3600;
$row = $this->db->fetch("SELECT MAX(created_at) as last_at FROM conversations WHERE user_id = :uid", ['uid' => $userId]);
$lastAt = $row && !empty($row['last_at']) ? strtotime($row['last_at']) : null;
if (!$lastAt) return false;
if ((time() - $lastAt) >= $sixHours) {
try {
$this->db->update('users', [
'current_menu_id' => null,
'current_step' => 0,
'session_data' => null,
'welcome_sent_at' => null,
'advisor_requested' => 0,
'on_hold' => 0,
'in_service' => 0,
'in_service_by' => null,
'bot_paused_until' => null
], 'id = :id', ['id' => $userId]);
// Registrar mensaje sistema para trazabilidad
$this->db->insert('conversations', [
'user_id' => $userId,
'direction' => 'outgoing',
'message_type' => 'text',
'content' => json_encode(['system' => 'system', 'type' => 'session_reset', 'text' => 'Sesión reiniciada por inactividad (6h).']),
'status' => 'sent',
'created_at' => date('Y-m-d H:i:s')
]);
error_log("[BotService] maybeResetConversationAfterIdle: reset session for user_id={$userId} after inactivity");
} catch (Exception $e) {
error_log('[BotService] maybeResetConversationAfterIdle -> failed to reset user state: ' . $e->getMessage());
}
return true;
}
} catch (Exception $e) {
error_log('[BotService] maybeResetConversationAfterIdle failed: ' . $e->getMessage());
}
return false;
}
// =========================================================================
// TÉRMINOS Y CONDICIONES
// =========================================================================
/**
* Obtiene la versión activa de términos o null si no existe ninguna.
*/
private function getActiveTermsVersion(): ?array {
try {
return $this->db->fetch(
"SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1"
) ?: null;
} catch (Exception $e) {
error_log('[BotService] getActiveTermsVersion error: ' . $e->getMessage());
return null;
}
}
/**
* Decide si el usuario debe aceptar (o re-aceptar) los términos.
* - No hay aceptación previa (terms_accepted_at IS NULL)
* - Han pasado más de 6 meses desde la última aceptación
* - La versión activa tiene forzar_reenvio=1 y el usuario aceptó una versión anterior
*/
private function needsTermsAcceptance(array $user): bool {
$terms = $this->getActiveTermsVersion();
if (!$terms) return false; // Sin versión activa → no hay términos que aceptar
// ¿El usuario ya aceptó esta versión exacta?
$acceptedVersionId = $user['terms_version_id'] ?? null;
$acceptedAt = $user['terms_accepted_at'] ?? null;
// Nunca aceptó
if (!$acceptedAt) return true;
// Forzar re-aceptación si la versión activa lo requiere y el usuario tiene una versión diferente
if (!empty($terms['forzar_reenvio']) && (int)$acceptedVersionId !== (int)$terms['id']) {
return true;
}
// Han pasado más de 6 meses (26 semanas) desde la última aceptación
$sixMonthsAgo = strtotime('-6 months');
if (strtotime($acceptedAt) < $sixMonthsAgo) return true;
return false;
}
/**
* Envía el mensaje de términos al usuario y registra el envío en BD.
*/
private function sendTermsMessage(array $user, string $phoneNumber): void {
$terms = $this->getActiveTermsVersion();
if (!$terms) return;
// Prioridad: system_config (lo que edita el usuario en "Configuración del Sistema")
// Fallback: terms_versions.mensaje_aceptacion (si el admin lo editó directamente en la BD)
$msg = getConfigFromDB('terms_message', '');
if (!$msg && !empty($terms['mensaje_aceptacion'])) {
$msg = $terms['mensaje_aceptacion'];
}
if (!$msg) return;
// Adjuntar URL del documento solo si es una URL pública y no está ya incluida en el mensaje
// Normalizar protocolo para la comparación (http vs https no deben importar)
if (!empty($terms['documento_url'])) {
$docUrl = $terms['documento_url'];
$isLocal = (
strpos($docUrl, 'localhost') !== false ||
strpos($docUrl, '127.0.0.1') !== false ||
preg_match('/https?:\/\/\d+\.\d+\.\d+\.\d+:\d+/', $docUrl)
);
$docUrlNormalized = preg_replace('#^https?://#', '', $docUrl);
$msgNormalized = preg_replace('#https?://#', '', $msg);
if (!$isLocal && strpos($msgNormalized, $docUrlNormalized) === false) {
$msg .= "\n\n📄 Documento: " . $docUrl;
}
}
$this->whatsappService->sendTextMessage($phoneNumber, $msg);
// Marcar usuario como "esperando respuesta de términos"
// También marcamos welcome_sent_at para evitar que una llamada concurrente
// al webhook envíe el mensaje de bienvenida por race condition.
try {
$this->db->update(
'users',
['terms_pending' => 1, 'welcome_sent_at' => date('Y-m-d H:i:s')],
'id = :id',
['id' => $user['id']]
);
} catch (Exception $e) {
error_log('[BotService] sendTermsMessage update terms_pending: ' . $e->getMessage());
}
// Registrar envío en terms_acceptance con estado pendiente
try {
$this->db->insert('terms_acceptance', [
'user_id' => $user['id'],
'terms_version_id' => $terms['id'],
'phone_number' => $phoneNumber,
'estado' => 'pendiente',
'fecha_envio' => date('Y-m-d H:i:s'),
]);
} catch (Exception $e) {
error_log('[BotService] sendTermsMessage insert terms_acceptance: ' . $e->getMessage());
}
error_log("[BotService] sendTermsMessage sent terms v{$terms['version']} to {$phoneNumber}");
}
/**
* Procesa la respuesta del usuario cuando está en estado terms_pending.
* Detecta "acepto" / "no acepto" / "rechazo".
* Devuelve true si el mensaje fue manejado (para detener el procesamiento normal).
*/
private function processTermsResponse(array $user, string $phoneNumber, string $messageText): bool {
$terms = $this->getActiveTermsVersion();
if (!$terms) {
// Sin versión activa: limpiar el flag y continuar
try {
$this->db->update('users', ['terms_pending' => 0], 'id = :id', ['id' => $user['id']]);
} catch (Exception $e) {}
return false;
}
$normalized = strtolower(trim(
iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $messageText) ?: $messageText
));
// Detectar aceptación: "acepto", "si acepto", "sí, acepto", "1", etc.
$accepted = (bool) preg_match('/\b(acepto|si acepto|si|sí|1|ok|aceptar|de acuerdo|confirmo)\b/', $normalized);
// Detectar rechazo explícito: "no acepto", "rechazo", "no", "0", etc.
$rejected = !$accepted && (bool) preg_match('/\b(no acepto|no|rechazo|rechazar|negar|0|rechaz[oa])\b/', $normalized);
if (!$accepted && !$rejected) {
// Respuesta no reconocida → volver a preguntar
$retry = "No entendí tu respuesta. Por favor responde *Acepto* o *No acepto*.";
$this->whatsappService->sendTextMessage($phoneNumber, $retry);
return true;
}
$estado = $accepted ? 'aceptado' : 'rechazado';
$now = date('Y-m-d H:i:s');
// Actualizar el último registro pendiente de esta versión
try {
$this->db->query(
"UPDATE terms_acceptance SET estado = :estado, fecha_respuesta = :now
WHERE user_id = :uid AND terms_version_id = :vid AND estado = 'pendiente'
ORDER BY id DESC LIMIT 1",
['estado' => $estado, 'now' => $now, 'uid' => $user['id'], 'vid' => $terms['id']]
);
} catch (Exception $e) {
error_log('[BotService] processTermsResponse update acceptance: ' . $e->getMessage());
}
if ($accepted) {
// Actualizar usuario: aceptó términos
try {
$this->db->update('users', [
'terms_pending' => 0,
'terms_accepted_at' => $now,
'terms_version_id' => $terms['id'],
], 'id = :id', ['id' => $user['id']]);
} catch (Exception $e) {
error_log('[BotService] processTermsResponse update user accepted: ' . $e->getMessage());
}
// Ir directo al menú principal.
// El mensaje de T&C ya contiene el saludo de bienvenida,
// NO se envía el "Mensaje de Bienvenida" por separado para evitar duplicados.
$this->showMainMenu($phoneNumber);
} else {
// Rechazó — enviar mensaje de rechazo y dejar terms_pending=1 para que pueda aceptar después
// Prioridad: system_config (lo que edita el usuario en "Configuración del Sistema")
$rejMsg = getConfigFromDB('terms_rejected_message', '');
if (!$rejMsg && !empty($terms['mensaje_rechazo'])) {
$rejMsg = $terms['mensaje_rechazo'];
}
if (!$rejMsg) {
$rejMsg = 'Lamentablemente no podemos proceder sin tu aceptación. Cuando estés listo, responde *Acepto*.';
}
$this->whatsappService->sendTextMessage($phoneNumber, $rejMsg);
// Mantener terms_pending = 1 para que la próxima respuesta sea evaluada
}
error_log("[BotService] processTermsResponse user {$user['id']} {$estado} terms v{$terms['version']}");
return true;
}
// =========================================================================
// FIN TÉRMINOS Y CONDICIONES
// =========================================================================
/**
* Verificar si es un nuevo usuario
* Ahora: reenvía el welcome si no se envió o si el último welcome fue hace >= 6 horas
*/
private function isNewUser($userId) {
$messageCount = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations WHERE user_id = :user_id",
['user_id' => $userId]
);
// Evitar reenviar welcome si ya se envió recientemente
$user = $this->db->fetch("SELECT welcome_sent_at FROM users WHERE id = :id", ['id' => $userId]);
$welcomeSentAt = !empty($user['welcome_sent_at']) ? strtotime($user['welcome_sent_at']) : null;
// Si nunca se envió el welcome, consideramos nuevo
if (!$welcomeSentAt) return true;
// Si el welcome fue hace 6 horas o más, consideramos que debe reenviarse
$sixHours = 6 * 3600;
if ((time() - $welcomeSentAt) >= $sixHours) return true;
// En otros casos, no es nuevo
return false;
}
/**
* Enviar mensaje de bienvenida
*/
public function sendWelcomeMessage($phoneNumber) {
// No enviar bienvenida si hay términos activos y el usuario aún no los ha aceptado
$termsVersion = $this->getActiveTermsVersion();
if ($termsVersion) {
$userRow = $this->db->fetch("SELECT terms_accepted_at FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
if (empty($userRow['terms_accepted_at'])) {
error_log("[BotService] sendWelcomeMessage - skipped for {$phoneNumber}: terms not accepted yet");
return;
}
}
$enabled = getConfigFromDB('welcome_enabled', '1');
$welcomeMessage = getConfigFromDB('welcome_message', '¡Hola! 👋 Bienvenido a nuestro servicio automatizado. Escriba *MENU* para ver las opciones disponibles.');
if (!$enabled || !$welcomeMessage) return;
$this->whatsappService->sendTextMessage($phoneNumber, $welcomeMessage);
// Registrar que se envió el welcome
try {
$this->db->update('users', ['welcome_sent_at' => date('Y-m-d H:i:s')], 'phone_number = :phone', ['phone' => $phoneNumber]);
// Asegurar que este envío no sea interpretado como respuesta de un asesor:
// Limpiar banderas de in_service / advisor_requested y evitar que la UI marque la conversación como atendida.
try {
$this->db->update('users', ['in_service' => 0, 'in_service_by' => null, 'advisor_requested' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
} catch (Exception $e) {
error_log('Failed to clear in_service/advisor flags after welcome: ' . $e->getMessage());
}
} catch (Exception $e) {
error_log('Failed to set welcome_sent_at for ' . $phoneNumber . ': ' . $e->getMessage());
}
}
/**
* Procesar comandos especiales
*/
private function processSpecialCommands($phoneNumber, $messageText) {
$command = strtolower($messageText);
try { error_log("[BotService] processSpecialCommands - command={$command} phone={$phoneNumber}"); } catch (Throwable $t) {}
switch ($command) {
case 'menu':
case 'menú':
case 'inicio':
try { error_log("[BotService] processSpecialCommands - detected MENU command, calling showMainMenu for phone={$phoneNumber}"); } catch (Throwable $t) {}
$this->showMainMenu($phoneNumber);
return true;
case 'salir':
case 'exit':
case 'cancelar':
$this->exitMenu($phoneNumber);
return true;
case 'atras':
case 'atrás':
case 'volver':
case 'back':
try { error_log("[BotService] processSpecialCommands - detected BACK command in global handler for phone={$phoneNumber}"); } catch (Throwable $t) {}
// Resolver current_menu_id desde users y decidir la acción más adecuada
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$currentMenuId = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$currentMenuId = null;
}
if (!empty($currentMenuId)) {
$this->goToParentMenu($phoneNumber, $currentMenuId);
} else {
// Intentar usar historial
$this->goToPreviousMenu($phoneNumber);
}
return true;
case 'asesor':
case 'ayuda':
// Solicitar asesor: marcar solicitud y pausar brevemente (3 minutos)
$this->requestAdvisor($phoneNumber, 3);
return true;
}
return false;
}
/**
* Mostrar menú principal
*/
private function showMainMenu($phoneNumber) {
try { error_log("[BotService] showMainMenu - phone={$phoneNumber} fetching root menu"); } catch (Throwable $t) {}
$mainMenu = $this->db->fetch(
"SELECT * FROM menus WHERE is_root = 1 AND is_active = 1 ORDER BY order_position LIMIT 1"
);
try { error_log("[BotService] showMainMenu - fetched root menu=" . json_encode($mainMenu)); } catch (Throwable $t) {}
if ($mainMenu) {
$this->showMenu($phoneNumber, $mainMenu['id']);
} else {
try { error_log("[BotService] showMainMenu - no root menu found for phone={$phoneNumber}"); } catch (Throwable $t) {}
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ No hay menús configurados. Contacte con soporte."
);
}
}
/**
* Mostrar menú específico
*/
private function showMenu($phoneNumber, $menuId) {
try { error_log("[BotService] showMenu - phone={$phoneNumber} menuId={$menuId} fetching menu"); } catch (Throwable $t) {}
// Obtener información del menú
$menu = $this->db->fetch(
"SELECT * FROM menus WHERE id = :id AND is_active = 1",
['id' => $menuId]
);
try { error_log("[BotService] showMenu - fetched menu=" . json_encode($menu)); } catch (Throwable $t) {}
if (!$menu) {
try { error_log("[BotService] showMenu - menu not found id={$menuId} phone={$phoneNumber}"); } catch (Throwable $t) {}
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Menú no encontrado."
);
return;
}
// Obtener opciones del menú
$options = $this->db->fetchAll(
"SELECT * FROM menu_options WHERE menu_id = :menu_id AND is_active = 1 ORDER BY option_number",
['menu_id' => $menuId]
);
try { error_log("[BotService] showMenu - menuId={$menuId} options_count=" . count($options)); } catch (Throwable $t) {}
if (empty($options)) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Este menú no tiene opciones configuradas."
);
return;
}
// Construir mensaje del menú
$menuText = "📋 *" . $menu['title'] . "*\n\n";
if ($menu['description']) {
$menuText .= $menu['description'] . "\n\n";
}
foreach ($options as $option) {
$menuText .= $option['option_number'] . ". " . $option['text'] . "\n";
}
// Añadir opción "Atrás" si el menú tiene un menú padre
if (!empty($menu['parent_id'])) {
$menuText .= "0. Atrás\n";
}
$menuText .= "\n💬 *Responda con el número de la opción que desea*";
// Actualizar estado del usuario
$this->updateUserMenuState($phoneNumber, $menuId);
// Enviar mensaje
$this->whatsappService->sendTextMessage($phoneNumber, $menuText);
}
/**
* Procesar selección de menú
*/
private function processMenuSelection($user, $messageText) {
$phoneNumber = $user['phone_number'];
$currentMenuId = null;
// Intentar primero el servicio de estados (si existe) — esto permite flujos que usan user_states en lugar de la columna users.current_menu_id
try {
$stateSvc = new ConversationStateService();
$currentMenuId = $stateSvc->getCurrentMenuId($phoneNumber);
} catch (Exception $e) {
// ignore if service not available
}
// Si no tenemos valor desde el servicio de estados, consultar columna users
if (empty($currentMenuId)) {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$currentMenuId = $cur ? $cur['current_menu_id'] : ($user['current_menu_id'] ?? null);
}
// DEBUG
try { error_log("[BotService] processMenuSelection - RESOLVED menu_id={$currentMenuId} for phone={$phoneNumber} user_id={$user['id']} incoming='{$messageText}'"); } catch (Throwable $t) {}
// Prioritario: si estamos esperando documentos para esta conversación, manejarlo aquí
try {
$stateSvcTemp = new ConversationStateService();
$await = $stateSvcTemp->getStateData($phoneNumber, 'awaiting_documents');
} catch (Exception $e) {
$await = null;
}
if (!empty($await)) {
// Si llega multimedia (no text), marcar recibido y enviar ACK
if ($messageType !== 'text') {
try {
$stateSvcTemp->updateStateData($phoneNumber, ['awaiting_documents' => ['since' => $await['since'], 'received' => true]]);
} catch (Exception $e) {
// ignore
}
try {
$this->whatsappService->sendTextMessage($phoneNumber, "✅ Documento recibido. Cuando haya terminado, escriba *ENVIADA*.");
} catch (Exception $e) {
// ignore
}
return; // no procesar más
}
// Si es texto y dice enviada => transferir a asesor
if (preg_match('/\b(enviad[oa]s?)\b/iu', $messageText)) {
try {
$this->requestAdvisor($phoneNumber, 3);
// limpiar estado awaiting_documents
$stateSvcTemp->updateStateData($phoneNumber, ['awaiting_documents' => null]);
// registrar sistema para trazabilidad
$sys = [
'system' => 'user_documents_sent',
'type' => 'user_confirmed_documents',
'text' => 'Usuario confirmó envío de documentos con la palabra ENVIADA.'
];
$this->db->insert('conversations', [
'user_id' => $user['id'],
'direction' => 'incoming',
'message_type' => 'text',
'content' => json_encode($sys),
'status' => 'received',
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('[BotService] failed to process ENVIADA: ' . $e->getMessage());
}
return;
}
// Permitir navegación explícita (MENU/ATRÁS/números) y en los demás casos evitar respuestas automáticas
$cmd = mb_strtolower(trim($messageText));
$backCommands = ['atras', 'atrás', 'volver', 'back'];
if (in_array($cmd, ['menu','menú','inicio'], true)) {
$this->showMainMenu($phoneNumber);
return;
}
if (in_array($cmd, $backCommands, true)) {
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$currentMenuId = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$currentMenuId = null;
}
if (!empty($currentMenuId)) {
$this->goToParentMenu($phoneNumber, $currentMenuId);
} else {
$this->goToPreviousMenu($phoneNumber);
}
return;
}
if (is_numeric(trim($messageText))) {
try {
$stateSvc2 = new ConversationStateService();
$resolvedMenuId2 = $stateSvc2->getCurrentMenuId($phoneNumber);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
if (empty($resolvedMenuId2)) {
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$resolvedMenuId2 = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
if (empty($resolvedMenuId2)) {
try {
$hist = (array)$stateSvc2->getStateData($phoneNumber, 'menu_history');
if (!empty($hist)) $resolvedMenuId2 = end($hist);
} catch (Exception $e) {
$resolvedMenuId2 = null;
}
}
if (!empty($resolvedMenuId2)) {
try {
$this->db->update('users', ['current_menu_id' => $resolvedMenuId2, 'current_step' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
try {
$stateSvc2->setCurrentMenu($phoneNumber, (int)$resolvedMenuId2);
} catch (Exception $e) {
// ignore
}
} catch (Exception $e) {
error_log('[BotService] failed to set current_menu_id before processing numeric selection: ' . $e->getMessage());
}
$user['current_menu_id'] = $resolvedMenuId2;
$this->processMenuSelection($user, $messageText);
return;
}
}
// Si llegamos aquí y no es navegación, enviamos un aviso simple para que escriba ENVIADA
try {
$this->whatsappService->sendTextMessage($phoneNumber, "Si ya enviaste los documentos, escriba *ENVIADA* cuando hayas terminado. Si necesita volver al menú, escriba *MENU*. ");
} catch (Exception $e) {
// ignore
}
return;
}
// Soportar comando "atrás" o "volver" para navegar al menú padre o al menú previo
$cmd = mb_strtolower(trim($messageText));
$backCommands = ['atras', 'atrás', 'volver', 'back'];
if (in_array($cmd, $backCommands, true)) {
try { error_log("[BotService] processMenuSelection - BACK command detected for phone={$phoneNumber} resolved_menu={$currentMenuId}"); } catch (Throwable $t) {}
if (!empty($currentMenuId)) {
$this->goToParentMenu($phoneNumber, $currentMenuId);
} else {
// Si no hay menú actual, intentar volver al menú previo almacenado en historial
$this->goToPreviousMenu($phoneNumber);
}
return;
}
// Verificar si es un número
if (!is_numeric($messageText)) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Por favor, responda solo con el número de la opción deseada, la palabra *MENU* o *ATRÁS* para volver."
);
error_log("[BotService] processMenuSelection - non-numeric response from user {$user['id']}: '{$messageText}'");
return;
}
$optionNumber = (int)$messageText;
// Si eligió 0 => volver al menú padre
if ($optionNumber === 0) {
$this->goToParentMenu($phoneNumber, $currentMenuId);
return;
}
// Buscar la opción seleccionada por número
$option = $this->db->fetch(
"SELECT * FROM menu_options WHERE menu_id = :menu_id AND option_number = :option_number AND is_active = 1",
['menu_id' => $currentMenuId, 'option_number' => $optionNumber]
);
// Si no encontramos por número, intentar buscar por texto (caso de replies interactivos que envían título)
if (!$option) {
$txt = strtolower(trim($messageText));
if (!empty($txt)) {
$option = $this->db->fetch(
"SELECT * FROM menu_options WHERE menu_id = :menu_id AND is_active = 1 AND (LOWER(text) = :txt OR LOWER(text) LIKE :like) LIMIT 1",
['menu_id' => $currentMenuId, 'txt' => $txt, 'like' => "%{$txt}%"]
);
if ($option) {
error_log("[BotService] processMenuSelection - matched option by text for user {$user['id']} menu={$currentMenuId} text='{$txt}' => option_number={$option['option_number']}");
}
}
}
if (!$option) {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Opción inválida. Por favor, seleccione una opción válida del menú."
);
error_log("[BotService] processMenuSelection - no option found for user {$user['id']} menu={$currentMenuId} option={$optionNumber}");
return;
}
error_log("[BotService] processMenuSelection - option found for user {$user['id']} menu={$currentMenuId} option={$optionNumber} action={$option['action_type']}");
// Procesar acción de la opción
$this->processMenuAction($phoneNumber, $option);
}
/**
* Procesar acción del menú
*/
private function processMenuAction($phoneNumber, $option) {
switch ($option['action_type']) {
case 'menu':
// Navegar a otro menú
$targetMenu = $this->db->fetch(
"SELECT * FROM menus WHERE name = :name AND is_active = 1",
['name' => $option['action_value']]
);
if ($targetMenu) {
$this->showMenu($phoneNumber, $targetMenu['id']);
} else {
$this->whatsappService->sendTextMessage(
$phoneNumber,
"❌ Menú no encontrado: " . $option['action_value']
);
}
break;
case 'message':
// Enviar mensaje de respuesta (procesando saltos de línea)
$message = $this->processLineBreaks($option['action_value']);
$this->whatsappService->sendTextMessage($phoneNumber, $message);
$this->exitMenu($phoneNumber);
break;
case 'api_call':
// Llamar API externa (implementar según necesidad)
$this->processApiCall($phoneNumber, $option['action_value']);
break;
case 'request_documents':
// Solicitar al usuario que envíe documentación y activar estado awaiting_documents
$message = $option['action_value'] ?: "Por favor, envíe los documentos o fotos requeridas.";
$message = $this->processLineBreaks($message);
$this->whatsappService->sendTextMessage($phoneNumber, $message);
try {
$stateSvc = new ConversationStateService();
$stateSvc->updateStateData($phoneNumber, ['awaiting_documents' => ['since' => date('c'), 'received' => false]]);
$this->whatsappService->sendTextMessage($phoneNumber, "Cuando hayas enviado los documentos, escribe *ENVIADA* para que un asesor lo revise.");
$sys = [
'system' => 'awaiting_documents_started',
'type' => 'awaiting_documents',
'text' => 'Se solicitó documentación y se espera confirmación ENVIADA.'
];
$this->db->insert('conversations', [
'user_id' => $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber])['id'] ?? null,
'direction' => 'outgoing',
'message_type' => 'text',
'content' => json_encode($sys),
'status' => 'sent',
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('[BotService] request_documents failed: ' . $e->getMessage());
}
$this->exitMenu($phoneNumber);
break;
case 'end':
// Finalizar conversación (comportamiento por defecto)
$message = $option['action_value'] ?: "Gracias por usar nuestro servicio. ¡Hasta pronto!";
$message = $this->processLineBreaks($message);
$this->whatsappService->sendTextMessage($phoneNumber, $message);
$this->exitMenu($phoneNumber);
// Pausar el bot para esta conversación por 3 minutos (antes eran 4 horas)
$this->pauseConversationForMinutes($phoneNumber, 3);
break;
}
}
/**
* Procesar texto para respetar saltos de línea
* Convierte \n escapados a saltos reales
*/
private function processLineBreaks($text) {
if (empty($text)) return $text;
// Convertir \n escapados (cadena literal) a saltos de línea reales
$text = str_replace('\\n', "\n", $text);
$text = str_replace('\n', "\n", $text);
return $text;
}
/**
* Procesar llamada API externa
*/
private function processApiCall($phoneNumber, $apiEndpoint) {
// Aquí puedes implementar llamadas a APIs externas
// Por ejemplo, consultar saldos, procesar pagos, etc.
$this->whatsappService->sendTextMessage(
$phoneNumber,
"🔄 Procesando su solicitud... Un momento, por favor."
);
// Simular procesamiento
sleep(2);
$this->whatsappService->sendTextMessage(
$phoneNumber,
"✅ Su solicitud ha sido procesada con éxito."
);
$this->exitMenu($phoneNumber);
}
/**
* Procesar respuestas automáticas
*/
private function processAutoResponses($phoneNumber, $messageText) {
$keyword = strtolower($messageText);
// Buscar respuesta automática por keyword
$autoResponse = $this->db->fetch(
"SELECT * FROM autoresponses WHERE trigger_type = 'keyword' AND LOWER(trigger_value) = :keyword AND is_active = 1",
['keyword' => $keyword]
);
if ($autoResponse) {
$message = $this->processLineBreaks($autoResponse['response_text']);
$this->whatsappService->sendTextMessage($phoneNumber, $message);
// Si la keyword es 'menu', mostrar el menú
if ($keyword === 'menu' || $keyword === 'menú') {
$this->showMainMenu($phoneNumber);
}
} else {
// Respuesta por defecto para mensajes no reconocidos cuando se llama explícitamente
$this->sendDefaultNoMatch($phoneNumber);
}
}
/**
* Enviar mensaje por defecto cuando no hay coincidencias
*/
private function sendDefaultNoMatch($phoneNumber) {
$defaultMessage = "🤖 No entendí su mensaje. Escriba *MENU* para ver las opciones disponibles o *ASESOR* para obtener ayuda.";
$this->whatsappService->sendTextMessage($phoneNumber, $defaultMessage);
}
/**
* Salir del menú actual
* Guarda el menú previo en el historial para permitir "ATRÁS" después de salir
*/
private function exitMenu($phoneNumber) {
// Obtener menú actual antes de limpiar
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$previousMenu = $cur ? $cur['current_menu_id'] : null;
if (!empty($previousMenu)) {
try {
$this->pushMenuToHistory($phoneNumber, $previousMenu);
} catch (Exception $e) {
error_log('[BotService] exitMenu - failed to push previous menu: ' . $e->getMessage());
}
}
} catch (Exception $e) {
// ignore
}
$this->db->update(
'users',
['current_menu_id' => null, 'current_step' => 0, 'session_data' => null],
'phone_number = :phone',
['phone' => $phoneNumber]
);
// Mantener el estado en ConversationStateService (no borrar el historial)
try {
$stateSvc = new ConversationStateService();
$stateSvc->setCurrentMenu($phoneNumber, null);
} catch (Exception $e) {
// ignore
}
}
/**
* Poner la conversación en pausa por X minutos (llamada desde opciones 'end')
*/
private function pauseConversationForMinutes($phoneNumber, $minutes = 3) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
$this->db->update('users', ['bot_paused_until' => $pausedUntil], 'phone_number = :phone', ['phone' => $phoneNumber]);
if (function_exists('writeLog')) writeLog('INFO', "Bot paused for {$minutes} minutes for {$phoneNumber}");
} catch (Exception $e) {
error_log('pauseConversationForMinutes failed: ' . $e->getMessage());
}
}
// Compatibilidad: wrapper que acepta horas para llamadas antiguas
private function pauseConversationForHours($phoneNumber, $hours = 4) {
$minutes = intval($hours * 60);
$this->pauseConversationForMinutes($phoneNumber, $minutes);
}
/**
* Poner conversación en espera (asesor solicitado) por X minutos
*/
public function putOnHold($phoneNumber, $minutes = 3) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
// Guardar bandera y tiempo de expiración (usamos bot_paused_until que ya existe)
$this->db->update('users', ['on_hold' => 1, 'bot_paused_until' => $pausedUntil, 'advisor_requested' => 1, 'bot_enabled' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
// Enviar mensaje (bot) marcando la solicitud; no incluimos metadata de operador para evitar limpiar la marca
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Se ha solicitado un asesor. Se le ha puesto en espera por {$minutes} minutos. Si no hay respuesta, podrá volver a usar *MENU*." );
if (function_exists('writeLog')) writeLog('INFO', "Advisor requested for $phoneNumber until $pausedUntil");
} catch (Exception $e) {
error_log('putOnHold failed: ' . $e->getMessage());
}
}
/**
* Registrar solicitud de asesor sin poner on_hold (usuario lo solicita, se notifica al equipo)
*/
public function requestAdvisor($phoneNumber, $minutes = 3) {
try {
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
// No ponemos on_hold para evitar bloqueo automático; usamos advisor_requested
// Marcar solicitud de asesor y pausar bot temporalmente
$this->db->update('users', ['advisor_requested' => 1, 'bot_paused_until' => $pausedUntil, 'bot_enabled' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Le hemos transferido a un asesor; le responderemos en breve.");
// Enviar automáticamente el menú de horario_asesor para que el cliente sepa cuándo esperar
try {
$horarioMenu = $this->db->fetch(
"SELECT id FROM menus WHERE name = :name AND is_active = 1",
['name' => 'horario_asesor']
);
if ($horarioMenu && !empty($horarioMenu['id'])) {
$this->showMenu($phoneNumber, $horarioMenu['id']);
error_log("[BotService] Sent horario_asesor menu to {$phoneNumber} after advisor request");
} else {
error_log("[BotService] horario_asesor menu not found when requesting advisor for {$phoneNumber}");
}
} catch (Exception $e) {
error_log('Failed to send horario_asesor menu on advisor request: ' . $e->getMessage());
}
if (function_exists('writeLog')) writeLog('INFO', "Advisor solicited for $phoneNumber until $pausedUntil");
} catch (Exception $e) {
error_log('requestAdvisor failed: ' . $e->getMessage());
}
}
public function releaseHold($phoneNumber, $notify = true) {
try {
// Clear hold/advisor flags and also ensure in_service is cleared for consistency
// Clear hold and reactivate bot
$this->db->update('users', ['on_hold' => 0, 'bot_paused_until' => null, 'advisor_requested' => 0, 'in_service' => 0, 'in_service_by' => null, 'in_service_at' => null, 'bot_enabled' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
// Send notification only when requested (avoid duplicate messages after finish)
if ($notify) {
try {
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Su conversación ha sido retomada por el equipo. Puede usar *MENU* para continuar.");
} catch (Exception $e) {
// ignore send errors
}
}
if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber notify=" . ($notify ? '1' : '0'));
} catch (Exception $e) {
error_log('releaseHold failed: ' . $e->getMessage());
}
}
/**
* Si ha pasado 6 horas desde la última actividad, reinicia el estado de la conversación para que
* el bot vuelva a enviar el mensaje de bienvenida y el usuario empiece
/**
* Marcar la conversación como "en servicio" por un operador (Atender)
*/
public function attendConversation($phoneNumber, $operatorId = null) {
try {
$now = date('Y-m-d H:i:s');
// Marcar como en servicio y desactivar el bot temporalmente (solo mientras el operador atiende)
$update = ['in_service' => 1, 'in_service_at' => $now, 'advisor_requested' => 0, 'bot_enabled' => 0];
if ($operatorId) $update['in_service_by'] = $operatorId;
$this->db->update('users', $update, 'phone_number = :phone', ['phone' => $phoneNumber]);
// Notificar al usuario
try {
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Un asesor está atendiendo su conversación. Por favor, espere su respuesta.");
// ignore send errors
} catch (Exception $e) {
// Log and continue if notification fails
error_log('attendConversation notify failed: ' . $e->getMessage());
}
// Registrar actividad del operador si hay información disponible
$user = $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber]);
$userId = $user['id'] ?? null;
$activity = [
'user_id' => $userId,
'operator_id' => $operatorId,
'action' => 'attend',
'details' => 'Operator started attending the conversation',
'created_at' => $now
];
try {
$this->db->insert('operator_activity', $activity);
} catch (Exception $e) {
if (function_exists('writeLog')) writeLog('WARN', 'Failed to insert operator_activity: ' . $e->getMessage());
error_log('Failed to insert operator_activity: ' . $e->getMessage());
}
if (function_exists('writeLog')) writeLog('INFO', "Operator {$operatorId} attending conversation for {$phoneNumber}");
} catch (Exception $e) {
error_log('attendConversation failed: ' . $e->getMessage());
throw $e;
}
}
/**
* Finalizar la atención: limpiar estado 'in_service' y enviar plantilla de encuesta
*/
public function finishAttendConversation($phoneNumber, $operatorId = null) {
try {
// Limpiar estado de 'in_service'
// Limpiar in_service y reactivar el bot
$this->db->update('users', ['in_service' => 0, 'in_service_by' => null, 'in_service_at' => null, 'advisor_requested' => 0, 'bot_enabled' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
// Enviar plantilla de encuesta (configurable)
$template = getConfigFromDB('survey_template', 'encuesta_satisfaccin');
$lang = getConfigFromDB('survey_template_language', 'es');
try {
$this->whatsappService->sendTemplateMessage($phoneNumber, $template, $lang, []);
} catch (Exception $e) {
// Ignorar errores de envío de plantilla pero loguear
error_log('Failed to send survey template: ' . $e->getMessage());
if (function_exists('writeLog')) writeLog('WARN', 'Failed to send survey template: ' . $e->getMessage());
}
// Registrar actividad del operador
$user = $this->db->fetch('SELECT id FROM users WHERE phone_number = :phone', ['phone' => $phoneNumber]);
$userId = $user['id'] ?? null;
$now = date('Y-m-d H:i:s');
$activity = [
'user_id' => $userId,
'operator_id' => $operatorId,
'action' => 'finish',
'details' => "Finished attend and sent survey: {$template}",
'created_at' => $now
];
try {
$this->db->insert('operator_activity', $activity);
} catch (Exception $e) {
error_log('Failed to insert operator_activity on finish: ' . $e->getMessage());
}
if (function_exists('writeLog')) writeLog('INFO', "Operator {$operatorId} finished attending for {$phoneNumber}, survey={$template}");
// Ensure consistent behavior: release hold / clear flags when finishing attend
try {
// Release hold silently to avoid duplicate notification after finishing attend
$this->releaseHold($phoneNumber, false);
} catch (Exception $e) {
error_log('finishAttendConversation: releaseHold failed: ' . $e->getMessage());
}
} catch (Exception $e) {
error_log('finishAttendConversation failed: ' . $e->getMessage());
throw $e;
}
}
/**
* Ir al menú padre del menú actual
*/
private function goToParentMenu($phoneNumber, $currentMenuId) {
try {
$parentId = null;
$menu = null;
// Intentar resolver por ID numérico
if (!empty($currentMenuId) && is_numeric($currentMenuId)) {
$menu = $this->db->fetch("SELECT id, parent_id, name, title FROM menus WHERE id = :id", ['id' => (int)$currentMenuId]);
}
// Si no existe, intentar resolver por nombre (caso action_value = 'main_menu')
if (!$menu && !empty($currentMenuId)) {
$menu = $this->db->fetch("SELECT id, parent_id, name, title FROM menus WHERE name = :name", ['name' => $currentMenuId]);
}
if ($menu) {
$parentId = $menu['parent_id'] ?? null;
try { error_log("[BotService] goToParentMenu - resolved menu id={$menu['id']} name={$menu['name']} title='{$menu['title']}' parent_id=" . ($parentId ?? 'null') . " for phone={$phoneNumber}"); } catch (Throwable $t) {}
if (!empty($parentId)) {
$this->showMenu($phoneNumber, $parentId);
return;
} else {
// Si no hay padre, re-mostrar el menú actual (mejor UX)
if (!empty($menu['id'])) {
$this->showMenu($phoneNumber, $menu['id']);
return;
}
}
} else {
try { error_log("[BotService] goToParentMenu - could not resolve menu for currentMenuId='{$currentMenuId}' phone={$phoneNumber}"); } catch (Throwable $t) {}
}
// Fallback final a menú principal
$this->showMainMenu($phoneNumber);
} catch (Exception $e) {
error_log('[BotService] goToParentMenu failed: ' . $e->getMessage());
$this->showMainMenu($phoneNumber);
}
}
/**
* Actualizar estado del menú del usuario
* - Guarda el menú actual en el historial (ConversationStateService) antes de actualizar
*/
private function updateUserMenuState($phoneNumber, $menuId) {
// Obtener menú previo
try {
$cur = $this->db->fetch("SELECT current_menu_id FROM users WHERE phone_number = :phone", ['phone' => $phoneNumber]);
$previousMenu = $cur ? $cur['current_menu_id'] : null;
} catch (Exception $e) {
$previousMenu = null;
}
try { error_log("[BotService] updateUserMenuState - phone={$phoneNumber} previousMenu=" . json_encode($previousMenu) . " newMenu=" . json_encode($menuId)); } catch (Throwable $t) {}
// Si hay un menú previo diferente al nuevo, empujarlo al historial
if (!empty($previousMenu) && $previousMenu != $menuId) {
try {
$this->pushMenuToHistory($phoneNumber, $previousMenu);
} catch (Exception $e) {
error_log('[BotService] failed to push menu to history: ' . $e->getMessage());
}
}
// Actualizar columna users
$this->db->update(
'users',
['current_menu_id' => $menuId, 'current_step' => 1],
'phone_number = :phone',
['phone' => $phoneNumber]
);
// También actualizar ConversationStateService por compatibilidad
try {
$stateSvc = new ConversationStateService();
$stateSvc->setCurrentMenu($phoneNumber, (int)$menuId);
} catch (Exception $e) {
// ignore if service not available
}
}
/**
* Empujar un menú al historial del usuario (se almacena en ConversationStateService.state_data.menu_history)
*/
private function pushMenuToHistory(string $phoneNumber, $menuId) {
try {
$stateSvc = new ConversationStateService();
$currentState = $stateSvc->getState($phoneNumber);
// Si no existe una fila de estado, crearla con el historial inicial
if (!$currentState) {
try {
error_log("[BotService] pushMenuToHistory - no existing state, creating initial state with menu_history for phone={$phoneNumber} menuId={$menuId}");
$res = $stateSvc->setState($phoneNumber, ConversationStateService::STATE_INITIAL, ['menu_history' => [$menuId]]);
error_log('[BotService] pushMenuToHistory - setState result: ' . json_encode($res));
return $res;
} catch (Exception $e) {
error_log('[BotService] pushMenuToHistory create initial state failed: ' . $e->getMessage());
return false;
}
}
$history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history');
if (!is_array($history)) $history = [];
// Añadir al final y limitar a 10 entradas
$history[] = $menuId;
if (count($history) > 10) array_shift($history);
return $stateSvc->updateStateData($phoneNumber, ['menu_history' => $history]);
} catch (Exception $e) {
error_log('[BotService] pushMenuToHistory failed: ' . $e->getMessage());
return false;
}
}
/**
* Sacar el último menú del historial (LIFO) y retornarlo
*/
private function popMenuFromHistory(string $phoneNumber) {
try {
$stateSvc = new ConversationStateService();
$history = (array)$stateSvc->getStateData($phoneNumber, 'menu_history');
if (!is_array($history) || empty($history)) return null;
$last = array_pop($history);
$stateSvc->updateStateData($phoneNumber, ['menu_history' => $history]);
return $last;
} catch (Exception $e) {
error_log('[BotService] popMenuFromHistory failed: ' . $e->getMessage());
return null;
}
}
/**
* Ir al menú anterior según historial. Si no hay historial, fallback a main menu.
*/
private function goToPreviousMenu($phoneNumber) {
$prev = $this->popMenuFromHistory($phoneNumber);
if (!empty($prev)) {
// Intentar mostrar por ID primero
if (is_numeric($prev)) {
$this->showMenu($phoneNumber, (int)$prev);
return;
}
// Si no es numérico, intentar resolver por nombre
$menu = $this->db->fetch("SELECT id FROM menus WHERE name = :name", ['name' => $prev]);
if ($menu && !empty($menu['id'])) {
$this->showMenu($phoneNumber, $menu['id']);
return;
}
}
// Fallback
$this->showMainMenu($phoneNumber);
}
/**
* Obtener estadísticas del bot
*/
public function getBotStats() {
$stats = [];
// Total de usuarios
$stats['total_users'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM users"
)['count'];
// Usuarios activos (último mes)
$stats['active_users'] = $this->db->fetch(
"SELECT COUNT(DISTINCT user_id) as count FROM conversations
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
)['count'];
// Total de mensajes
$stats['total_conversations'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations"
)['count'];
// Mensajes hoy
$stats['conversations_today'] = $this->db->fetch(
"SELECT COUNT(*) as count FROM conversations
WHERE DATE(created_at) = CURDATE()"
)['count'];
return $stats;
}
}
?>