db = Database::getInstance(); } /** * Procesa un mensaje y encuentra la mejor respuesta automática * @param string $message * @param string $phoneNumber * @return array|null ['type' => 'autoresponse|menu|transfer', 'data' => array] */ public function processMessage(string $message, string $phoneNumber): ?array { // Normalizar mensaje $normalizedMessage = $this->normalizeText($message); // 1. Verificar si es una opción de menú numérica if (is_numeric(trim($message))) { return $this->processMenuOption($message, $phoneNumber); } // 2. Buscar respuesta automática por palabra clave $response = $this->findAutoResponse($normalizedMessage); if ($response) { return $this->formatResponse($response); } // 3. Si no encuentra nada, devolver respuesta por defecto return $this->getDefaultResponse(); } /** * Normaliza el texto para comparación * @param string $text * @return string */ private function normalizeText(string $text): string { // Convertir a minúsculas $text = mb_strtolower($text, 'UTF-8'); // Remover acentos $text = $this->removeAccents($text); // Remover emojis y caracteres especiales (excepto espacios y comas) $text = preg_replace('/[^\p{L}\p{N}\s,]/u', '', $text); // Remover espacios extras $text = preg_replace('/\s+/', ' ', $text); return trim($text); } /** * Remueve acentos de un texto * @param string $text * @return string */ private function removeAccents(string $text): string { $accents = [ 'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u', 'Á' => 'a', 'É' => 'e', 'Í' => 'i', 'Ó' => 'o', 'Ú' => 'u', 'ñ' => 'n', 'Ñ' => 'n', 'ü' => 'u', 'Ü' => 'u' ]; return strtr($text, $accents); } /** * Busca la mejor respuesta automática para el mensaje * @param string $normalizedMessage * @return array|null */ private function findAutoResponse(string $normalizedMessage): ?array { // Obtener todas las respuestas automáticas activas ordenadas por prioridad $stmt = $this->db->prepare(" SELECT * FROM autoresponses WHERE is_active = 1 AND trigger_type != 'welcome' ORDER BY priority DESC, id ASC "); $stmt->execute(); $autoResponses = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($autoResponses as $response) { if ($this->matchesTrigger($normalizedMessage, $response)) { return $response; } } return null; } /** * Verifica si un mensaje coincide con un trigger * @param string $message * @param array $response * @return bool */ private function matchesTrigger(string $message, array $response): bool { $triggerType = $response['trigger_type']; $triggerValue = $response['trigger_value']; switch ($triggerType) { case 'keyword': return $this->matchesKeywords($message, $triggerValue); case 'contains': return $this->containsPhrase($message, $triggerValue); case 'exact': return $this->exactMatch($message, $triggerValue); case 'default': return true; // Siempre coincide (pero tiene prioridad baja) default: return false; } } /** * Verifica si el mensaje contiene alguna palabra clave * @param string $message * @param string $keywordsString Palabras separadas por comas * @return bool */ private function matchesKeywords(string $message, string $keywordsString): bool { if (empty($keywordsString)) { return false; } $keywords = array_map('trim', explode(',', $keywordsString)); $keywords = array_map([$this, 'normalizeText'], $keywords); $messageWords = explode(' ', $message); foreach ($keywords as $keyword) { // Verificar si la palabra clave completa está en el mensaje if (strpos($message, $keyword) !== false) { return true; } // O si alguna palabra del mensaje coincide exactamente if (in_array($keyword, $messageWords)) { return true; } } return false; } /** * Verifica si el mensaje contiene una frase * @param string $message * @param string $phrase * @return bool */ private function containsPhrase(string $message, string $phrase): bool { $normalizedPhrase = $this->normalizeText($phrase); return strpos($message, $normalizedPhrase) !== false; } /** * Verifica coincidencia exacta * @param string $message * @param string $exact * @return bool */ private function exactMatch(string $message, string $exact): bool { $normalizedExact = $this->normalizeText($exact); return $message === $normalizedExact; } /** * Procesa una opción numérica de menú * @param string $option * @param string $phoneNumber * @return array|null */ private function processMenuOption(string $option, string $phoneNumber): ?array { $stateService = new ConversationStateService(); $currentMenuId = $stateService->getCurrentMenuId($phoneNumber); if (!$currentMenuId) { // Si no hay menú actual, buscar el menú principal $stmt = $this->db->prepare(" SELECT id FROM menus WHERE menu_key = 'lab_menu_principal' AND status = 'active' LIMIT 1 "); $stmt->execute(); $menu = $stmt->fetch(PDO::FETCH_ASSOC); $currentMenuId = $menu ? $menu['id'] : null; } if (!$currentMenuId) { return null; } // Buscar la opción en el menú actual $stmt = $this->db->prepare(" SELECT * FROM menu_options WHERE menu_id = ? AND option_key = ? AND is_active = 1 LIMIT 1 "); $stmt->execute([$currentMenuId, trim($option)]); $menuOption = $stmt->fetch(PDO::FETCH_ASSOC); if (!$menuOption) { return null; } return $this->formatMenuOption($menuOption); } /** * Formatea una respuesta automática * @param array $response * @return array */ private function formatResponse(array $response): array { $result = [ 'type' => $response['response_type'], 'priority' => $response['priority'] ]; switch ($response['response_type']) { case 'text': $result['message'] = $response['response_text']; break; case 'menu': $result['menu_id'] = $response['menu_id']; break; case 'template': $result['template_name'] = $response['template_name'] ?? null; break; case 'transfer': $result['message'] = $response['response_text']; $result['transfer'] = true; break; } return $result; } /** * Formatea una opción de menú * @param array $menuOption * @return array */ private function formatMenuOption(array $menuOption): array { $result = [ 'type' => $menuOption['response_type'] ]; switch ($menuOption['response_type']) { case 'text': $result['message'] = $menuOption['response_text']; break; case 'menu': $result['menu_id'] = $menuOption['next_menu_id']; break; case 'transfer': $result['message'] = $menuOption['response_text']; $result['transfer'] = true; break; } return $result; } /** * Obtiene la respuesta por defecto * @return array */ private function getDefaultResponse(): array { $stmt = $this->db->prepare(" SELECT * FROM autoresponses WHERE trigger_type = 'default' AND is_active = 1 ORDER BY priority DESC LIMIT 1 "); $stmt->execute(); $response = $stmt->fetch(PDO::FETCH_ASSOC); if ($response) { return $this->formatResponse($response); } // Respuesta fallback hardcoded return [ 'type' => 'text', 'message' => "❓ No he comprendido tu mensaje.\n\nEscribe MENÚ para ver las opciones disponibles." ]; } /** * Detecta el sentimiento del mensaje (básico) * @param string $message * @return string positive|negative|neutral */ public function detectSentiment(string $message): string { $normalized = $this->normalizeText($message); // Palabras positivas $positiveWords = ['gracias', 'excelente', 'perfecto', 'bien', 'bueno', 'genial', 'ok', 'vale']; // Palabras negativas / frustración $negativeWords = ['no funciona', 'problema', 'error', 'mal', 'ayuda', 'no entiendo', 'dificil', 'complicado']; $positiveCount = 0; $negativeCount = 0; foreach ($positiveWords as $word) { if (strpos($normalized, $word) !== false) { $positiveCount++; } } foreach ($negativeWords as $word) { if (strpos($normalized, $word) !== false) { $negativeCount++; } } if ($negativeCount > $positiveCount) { return 'negative'; } elseif ($positiveCount > 0) { return 'positive'; } return 'neutral'; } /** * Determina si el mensaje indica frustración (para escalar a asesor) * @param string $message * @return bool */ public function detectsFrustration(string $message): bool { return $this->detectSentiment($message) === 'negative'; } }