1287 lines
60 KiB
PHP
1287 lines
60 KiB
PHP
<?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'];
|
|
|
|
// 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
|
|
}
|
|
|
|
// Verificar si es un nuevo usuario (enviar mensaje de bienvenida)
|
|
if ($this->isNewUser($user['id'])) {
|
|
$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)
|
|
$now = date('Y-m-d H:i:s');
|
|
$sys = [
|
|
'system' => 'attention',
|
|
'type' => 'user_sent_documents',
|
|
'text' => "📄 *Documentos recibidos*\n\nEl usuario confirmó que ha enviado documentación (mensaje: *ENVIADA*).\n\n• Usuario: {$phoneNumber} (ID: {$user['id']})\n• Hora: {$now}\n\nPor favor, revise los archivos en la conversación y atiéndalo lo antes posible."
|
|
];
|
|
|
|
$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
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verificar si es un nuevo usuario
|
|
*/
|
|
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ó
|
|
$user = $this->db->fetch("SELECT welcome_sent_at FROM users WHERE id = :id", ['id' => $userId]);
|
|
$welcomeSent = !empty($user['welcome_sent_at']);
|
|
return ($messageCount['count'] <= 1) && !$welcomeSent; // Solo el mensaje actual y si no se ha enviado welcome
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje de bienvenida
|
|
*/
|
|
public function sendWelcomeMessage($phoneNumber) {
|
|
$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
|
|
$this->whatsappService->sendTextMessage($phoneNumber, $option['action_value']);
|
|
$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.";
|
|
$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!";
|
|
$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 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) {
|
|
$this->whatsappService->sendTextMessage($phoneNumber, $autoResponse['response_text']);
|
|
|
|
// 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.");
|
|
|
|
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) {
|
|
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Su conversación ha sido retomada por el equipo. Puede usar *MENU* para continuar.");
|
|
}
|
|
|
|
if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber notify=" . ($notify ? '1' : '0'));
|
|
} catch (Exception $e) {
|
|
error_log('releaseHold failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 to the user
|
|
$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;
|
|
}
|
|
}
|
|
?>
|