token = $config['token']; $this->phoneNumberId = $config['phone_number_id']; $this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/'; // Si se especifica canal 'turnero', usar el phone ID del número turnero if ($canal === 'turnero') { $turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', ''); if ($turneroPhoneId) { $this->phoneNumberId = $turneroPhoneId; } } $this->db = Database::getInstance(); // Inicializar monitor de rate limit try { // Cargar autoloader si no está cargado if (!class_exists('Predis\Client')) { $autoloadPath = __DIR__ . '/../vendor/autoload.php'; if (file_exists($autoloadPath)) { require_once $autoloadPath; } } if (file_exists(__DIR__ . '/RateLimitMonitor.php')) { require_once __DIR__ . '/RateLimitMonitor.php'; $this->rateLimitMonitor = new RateLimitMonitor(); } } catch (Exception $e) { error_log("WhatsAppService: No se pudo inicializar RateLimitMonitor - " . $e->getMessage()); $this->rateLimitMonitor = null; } } /** * Enviar mensaje de texto */ public function sendTextMessage($to, $message, $meta = null) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'text', 'text' => [ 'body' => $message ] ]; if (!empty($meta) && is_array($meta)) { // internal metadata used by our application (not sent to WhatsApp API) $data['__app_meta'] = $meta; } return $this->sendMessage($data); } /** * Enviar mensaje con template */ public function sendTemplateMessage( $to, $templateName, $language = 'es', $bodyParameters = [], $headerParameters = [], $rawComponents = null, $meta = null, $dryRun = false ) { // Si se pasan components completos (por ejemplo flow/button/image), úsalos tal cual if (is_array($rawComponents) && !empty($rawComponents)) { $template = [ 'name' => $templateName, 'language' => [ 'code' => $language ], 'components' => $rawComponents ]; $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'template', 'template' => $template ]; return $dryRun ? $data : $this->sendMessage($data); } $components = []; // Normalizar parámetros de header (si vienen como strings -> convertir a objeto de texto) if (!empty($headerParameters)) { $normalizedHeader = []; foreach ($headerParameters as $hp) { if (is_array($hp) && isset($hp['type'])) { $normalizedHeader[] = $hp; } elseif (is_string($hp)) { $normalizedHeader[] = [ 'type' => 'text', 'text' => $hp ]; } } if (!empty($normalizedHeader)) { $components[] = [ 'type' => 'header', 'parameters' => $normalizedHeader ]; } } // Normalizar parámetros de body: soportar tres formatos: // 1) Indexed array of strings: ['Alice','+123'] => converted to text params in order // 2) Indexed array of param objects: [['type'=>'text','text'=>'Alice'], ...] => used as-is // 3) Associative array for NAMED params: ['name' => 'Alice'] => converted to {type:text, name: 'name', text: 'Alice'} if (!empty($bodyParameters)) { $normalizedBody = []; // Detect associative (named) arrays: if array has string keys $isNamed = false; foreach ($bodyParameters as $k => $v) { if (!is_int($k)) { $isNamed = true; break; } } if ($isNamed) { // bodyParameters is like ['name' => 'Alice', ...] // Convert named params to the WhatsApp API expected parameter objects foreach ($bodyParameters as $paramName => $paramValue) { if (is_array($paramValue) && isset($paramValue['type'])) { // already detailed param object: remove any unexpected keys like 'name' $paramObj = $paramValue; // ensure parameter name is set using 'parameter_name' (preferred) or fall back to 'name' if (!isset($paramObj['parameter_name']) && !isset($paramObj['name'])) { $paramObj['parameter_name'] = $paramName; } // remove legacy 'name' to avoid API errors if (isset($paramObj['name'])) unset($paramObj['name']); $normalizedBody[] = $paramObj; } else { // include parameter_name so WhatsApp recognizes named params $normalizedBody[] = [ 'type' => 'text', 'parameter_name' => $paramName, 'text' => (string)$paramValue ]; } } } else { // Indexed array: preserve objects or convert strings foreach ($bodyParameters as $bp) { if (is_array($bp) && isset($bp['type'])) { // ensure no unsupported keys like 'name' are passed through $bpCopy = $bp; unset($bpCopy['name']); $normalizedBody[] = $bpCopy; // already detailed } elseif (is_string($bp) || is_numeric($bp)) { $normalizedBody[] = [ 'type' => 'text', 'text' => (string)$bp ]; } } } if (!empty($normalizedBody)) { $components[] = [ 'type' => 'body', 'parameters' => $normalizedBody ]; } } $template = [ 'name' => $templateName, 'language' => [ 'code' => $language ] ]; if (!empty($components)) { $template['components'] = $components; } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'template', 'template' => $template ]; if (!empty($meta) && is_array($meta)) { // internal metadata used by our application (not sent to WhatsApp API) $data['__app_meta'] = $meta; } return $dryRun ? $data : $this->sendMessage($data); } /** * Enviar mensaje con botones interactivos */ public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null) { $interactive = [ 'type' => 'button', 'body' => [ 'text' => $bodyText ], 'action' => [ 'buttons' => $buttons ] ]; if ($header) { $interactive['header'] = [ 'type' => 'text', 'text' => $header ]; } if ($footer) { $interactive['footer'] = [ 'text' => $footer ]; } $data = [ 'messaging_product' => 'whatsapp', 'recipient_type' => 'individual', 'to' => $this->formatPhoneNumber($to), 'type' => 'interactive', 'interactive' => $interactive ]; return $this->sendMessage($data); } /** * Enviar mensaje con lista */ public function sendListMessage($to, $bodyText, $buttonText, $sections, $header = null, $footer = null) { $interactive = [ 'type' => 'list', 'body' => [ 'text' => $bodyText ], 'action' => [ 'button' => $buttonText, 'sections' => $sections ] ]; if ($header) { $interactive['header'] = [ 'type' => 'text', 'text' => $header ]; } if ($footer) { $interactive['footer'] = [ 'text' => $footer ]; } $data = [ 'messaging_product' => 'whatsapp', 'recipient_type' => 'individual', 'to' => $this->formatPhoneNumber($to), 'type' => 'interactive', 'interactive' => $interactive ]; return $this->sendMessage($data); } /** * Marcar mensaje como leído */ public function markAsRead($messageId) { $data = [ 'messaging_product' => 'whatsapp', 'status' => 'read', 'message_id' => $messageId ]; $url = $this->apiUrl . $this->phoneNumberId . '/conversations'; return $this->makeRequest('POST', $url, $data); } /** * Enviar imagen */ public function sendImageMessage($to, $imageUrl, $caption = null) { $image = ['link' => $imageUrl]; if ($caption) { $image['caption'] = $caption; } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'image', 'image' => $image ]; return $this->sendMessage($data); } /** * Enviar video */ public function sendVideoMessage($to, $videoUrl, $caption = null, $skipAutoSave = false) { $video = ['link' => $videoUrl]; if ($caption) { $video['caption'] = $caption; } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'video', 'video' => $video ]; if ($skipAutoSave) { $data['__skip_auto_save'] = true; } return $this->sendMessage($data); } /** * Enviar audio * @param string $to Número de destinatario * @param string $audioUrl URL del audio * @param bool $skipAutoSave Omitir guardado automático * @param bool $isVoice True para enviar como nota de voz (requiere .ogg con códec OPUS) */ public function sendAudioMessage($to, $audioUrl, $skipAutoSave = false, $isVoice = false) { $audioData = [ 'link' => $audioUrl ]; // Si es nota de voz, agregar parámetro ptt (push-to-talk) // Requiere archivo .ogg con códec OPUS if ($isVoice) { // NOTA: WhatsApp Cloud API no tiene parámetro 'ptt' directo para links. // Para notas de voz se recomienda subir el archivo y usar sendMediaById con is_voice. error_log('sendAudioMessage: is_voice=true pero usando link. Para notas de voz, subir archivo primero.'); } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'audio', 'audio' => $audioData ]; if ($skipAutoSave) { $data['__skip_auto_save'] = true; } return $this->sendMessage($data); } /** * Enviar documento */ public function sendDocumentMessage($to, $documentUrl, $filename = null, $caption = null, $skipAutoSave = false) { $document = ['link' => $documentUrl]; if ($filename) { $document['filename'] = $filename; } if ($caption) { $document['caption'] = $caption; } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'document', 'document' => $document ]; if ($skipAutoSave) { $data['__skip_auto_save'] = true; } return $this->sendMessage($data); } /** * Subir archivo multimedia a WhatsApp * Intenta primero con PHP curl. Si falla por SSL/conexión (errno 35, 56, 77), * reintenta con distintas opciones SSL y usa stream_context/proc_open como fallback. */ public function uploadMedia($filePath, $mimeType) { if (!file_exists($filePath)) { throw new Exception("Archivo no encontrado: $filePath"); } $url = $this->apiUrl . $this->phoneNumberId . '/media'; $errors = []; // ── Intento 1: PHP curl TLS 1.2 + sin ALPN + IPv4 (hasta 3 reintentos) ── // curl1 logra conectar por TCP pero puede fallar en el envío (errno 55). // Reintentar es útil: el error es del lado del CDN de Meta y suele desaparecer. $a1Opts = [ CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, CURLOPT_SSL_ENABLE_ALPN => false, // sin ALPN → HTTP/1.1 → CDN diferente (alcanzable) CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, ]; $a1 = null; for ($try = 1; $try <= 3; $try++) { $a1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, $a1Opts); if ($a1['ok']) return $a1['result']; error_log("WhatsAppService::uploadMedia a1 intento{$try} fail: errno={$a1['errno']} http={$a1['http']}"); if ($try < 3) sleep(1); } $errors[] = "curl1: #{$a1['errno']}({$a1['error']}) http={$a1['http']}"; // ── Intento 2: PHP curl SSL deshabilitado + sin ALPN + HTTP/1.1 ────────── // IMPORTANTE: ALPN desactivado es crítico — con ALPN activo curl resuelve a un // IP de graph.facebook.com que no tiene ruta en el contenedor (errno 7 inmediato). $a2 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_SSL_ENABLE_ALPN => false, // mismo truco que curl1 CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, ]); if ($a2['ok']) return $a2['result']; $errors[] = "curl2: #{$a2['errno']}({$a2['error']}) http={$a2['http']}"; error_log("WhatsAppService::uploadMedia a2 fail: {$errors[1]}"); // ── Intento 3: PHP stream_context (evita libcurl/OpenSSL completamente) ── // Usa el wrapper SSL de PHP (diferente al de libcurl), sin ALPN issues try { $result = $this->_streamContextUpload($url, $filePath, $mimeType); if ($result) return $result; } catch (Exception $e) { $errors[] = "stream: " . $e->getMessage(); error_log("WhatsAppService::uploadMedia stream fail: " . $e->getMessage()); } // ── Intento 4: proc_open + curl CLI con --ipv4 ────────────────────────── if (function_exists('proc_open')) { try { $result = $this->_procOpenUpload($url, $filePath, $mimeType); if ($result) return $result; } catch (Exception $e) { $errors[] = "proc_open: " . $e->getMessage(); error_log("WhatsAppService::uploadMedia proc_open fail: " . $e->getMessage()); } } throw new Exception( "Error al subir archivo a WhatsApp tras 4 intentos: " . implode(' | ', $errors) . ". Solución permanente: docker compose up --build (aplica parche OpenSSL en contenedor)." ); } /** * Helper interno: upload con PHP curl + opciones extra. * IMPORTANTE: $extraOpts siempre sobrescriben los base. IPv4 se fuerza en base. */ private function _curlUploadAttempt($url, $filePath, $mimeType, array $extraOpts = []) { $ch = curl_init(); // Base siempre incluye IPRESOLVE_V4; extraOpts puede sobreescribirlo si es necesario $base = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'messaging_product' => 'whatsapp', 'file' => new CURLFile($filePath, $mimeType), 'type' => $mimeType, ], CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $this->token, 'Expect:', // deshabilitar 100-Continue: evita errno 55 (Send failure) con Meta ], CURLOPT_TIMEOUT => 300, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, // siempre IPv4 salvo que extraOpts lo cambie ]; curl_setopt_array($ch, $base + $extraOpts); $response = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); $errno = curl_errno($ch); curl_close($ch); if ($errno !== 0 || ($http !== 200 && $http !== 201)) { return ['ok' => false, 'errno' => $errno, 'error' => $error, 'http' => $http]; } $result = json_decode($response, true); if (!isset($result['id'])) { return ['ok' => false, 'errno' => 0, 'error' => 'No ID: ' . substr($response, 0, 200), 'http' => $http]; } return ['ok' => true, 'result' => $result]; } /** * Upload usando PHP stream_context (evita libcurl y su OpenSSL completamente). * Construye manualmente el body multipart/form-data. */ private function _streamContextUpload($url, $filePath, $mimeType) { $boundary = '----WA' . bin2hex(random_bytes(16)); $fileContent = file_get_contents($filePath); if ($fileContent === false) { throw new Exception("No se pudo leer el archivo: $filePath"); } $filename = basename($filePath); // Construir multipart body manualmente $body = "--{$boundary}\r\n"; $body .= "Content-Disposition: form-data; name=\"messaging_product\"\r\n\r\n"; $body .= "whatsapp\r\n"; $body .= "--{$boundary}\r\n"; $body .= "Content-Disposition: form-data; name=\"type\"\r\n\r\n"; $body .= "{$mimeType}\r\n"; $body .= "--{$boundary}\r\n"; $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n"; $body .= "Content-Type: {$mimeType}\r\n\r\n"; $body .= $fileContent . "\r\n"; $body .= "--{$boundary}--\r\n"; $opts = [ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", [ 'Authorization: Bearer ' . $this->token, 'Content-Type: multipart/form-data; boundary=' . $boundary, 'Content-Length: ' . strlen($body), ]), 'content' => $body, 'timeout' => 300, 'ignore_errors' => true, 'protocol_version' => '1.1', ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, 'cafile' => '/etc/ssl/certs/ca-certificates.crt', ], ]; $ctx = stream_context_create($opts); $response = @file_get_contents($url, false, $ctx); // Obtener HTTP code de los response headers $httpCode = 0; if (isset($http_response_header) && is_array($http_response_header)) { foreach ($http_response_header as $h) { if (preg_match('#^HTTP/\S+ (\d+)#', $h, $m)) { $httpCode = intval($m[1]); } } } error_log("WhatsAppService::_streamContextUpload http={$httpCode} response=" . substr((string)$response, 0, 300)); if ($response === false || ($httpCode !== 200 && $httpCode !== 201)) { $err = error_get_last(); throw new Exception("stream_context HTTP {$httpCode}: " . ($err['message'] ?? 'sin respuesta')); } $result = json_decode($response, true); if (!isset($result['id'])) { throw new Exception("stream_context sin ID: " . substr($response, 0, 300)); } return $result; } /** * Upload usando proc_open + curl CLI con --ipv4 explícito. * proc_open hereda el entorno de red completo del proceso PHP-FPM. */ private function _procOpenUpload($url, $filePath, $mimeType) { // Localizar curl binary $curlBin = '/usr/bin/curl'; if (!file_exists($curlBin)) { foreach (['/usr/local/bin/curl', '/bin/curl'] as $c) { if (file_exists($c)) { $curlBin = $c; break; } } } if (!file_exists($curlBin)) { throw new Exception("curl CLI no encontrado"); } $tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_'); $args = [ $curlBin, '--silent', '--show-error', '--ipv4', // forzar IPv4 '--tlsv1.2', '--http1.1', // sin ALPN→HTTP/2: graph.facebook.com CDN alcanzable '--no-alpn', // desactiva ALPN extension (igual que CURLOPT_SSL_ENABLE_ALPN=false) '--insecure', // bypass SSL verify (fallback de último recurso) '--max-time', '300', '-o', $tmpResponse, '-w', '%{http_code}', '-X', 'POST', '-H', 'Authorization: Bearer ' . $this->token, '-H', 'Expect:', // deshabilitar 100-Continue: evita errno 55 con Meta API '-F', 'messaging_product=whatsapp', '-F', 'type=' . $mimeType, '-F', 'file=@' . $filePath . ';type=' . $mimeType, $url, ]; $cmd = implode(' ', array_map('escapeshellarg', $args)); $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $env = array_merge( $_ENV ?: [], ['HOME' => '/tmp', 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'] ); $proc = proc_open($cmd, $descriptors, $pipes, null, $env); if (!is_resource($proc)) { @unlink($tmpResponse); throw new Exception("proc_open no pudo iniciar el proceso"); } fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($proc); $httpCode = intval(trim($stdout)); $body = @file_get_contents($tmpResponse); @unlink($tmpResponse); error_log("WhatsAppService::_procOpenUpload exit={$exitCode} http={$httpCode} stderr=" . substr($stderr, 0, 300)); if ($exitCode !== 0) { throw new Exception("curl CLI exit {$exitCode}: " . trim($stderr)); } if ($httpCode !== 200 && $httpCode !== 201) { throw new Exception("CLI HTTP {$httpCode}: " . substr((string)$body, 0, 300)); } $result = json_decode($body, true); if (!isset($result['id'])) { throw new Exception("CLI sin ID: " . substr((string)$body, 0, 300)); } return $result; } /** * Enviar mensaje usando media_id (archivo ya subido a WhatsApp) * @param string $to Número de destinatario * @param string $mediaId ID del archivo en WhatsApp * @param string $mediaType Tipo: image, video, audio, document * @param string|null $caption Texto opcional (no aplica para audio) * @param string|null $filename Nombre archivo (solo document) * @param bool $skipAutoSave Omitir guardado automático * @param bool $isVoice Para audio: true = nota de voz con onda verde, false = audio normal */ public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false, $meta = null) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => $mediaType ]; $mediaData = ['id' => $mediaId]; if ($caption && in_array($mediaType, ['image', 'video', 'document'])) { $mediaData['caption'] = $caption; } if ($filename && $mediaType === 'document') { $mediaData['filename'] = $filename; } if ($mediaType === 'audio' && $isVoice) { error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.'); } $data[$mediaType] = $mediaData; if (!empty($meta) && is_array($meta)) { $data['__app_meta'] = $meta; } if ($skipAutoSave) { $data['__skip_auto_save'] = true; } return $this->sendMessage($data); } /** * Enviar mensaje principal */ private function sendMessage($data) { // Construir URL correcto para enviar mensajes (use /messages) $url = rtrim($this->apiUrl, '/') . '/' . $this->phoneNumberId . '/messages'; // Prepare payload (remove internal app meta before sending to WhatsApp API) $payload = $data; if (isset($payload['__app_meta'])) { unset($payload['__app_meta']); } // Debug log (payload sent to WhatsApp) error_log("WhatsApp API URL: " . $url); error_log("WhatsApp Token length: " . strlen($this->token)); error_log("WhatsApp Payload: " . json_encode($payload)); $response = $this->makeRequest('POST', $url, $payload); // Guardar mensaje enviado en la base de datos (respuesta puede contener 'messages' o 'conversations') // Solo guardar si no se solicitó skipAutoSave $skipAutoSave = isset($data['__skip_auto_save']) && $data['__skip_auto_save']; $sentMessageId = null; if ($response) { if (isset($response['messages'][0]['id'])) { $sentMessageId = $response['messages'][0]['id']; } elseif (isset($response['conversations'][0]['id'])) { $sentMessageId = $response['conversations'][0]['id']; } } if ($response && $sentMessageId && !$skipAutoSave) { // Asegurar que la estructura de respuesta para saveOutgoingMessage se mantenga pasando el id $responseWrapper = $response; // Normalizar para compatibilidad con saveOutgoingMessage if (!isset($responseWrapper['conversations']) && $sentMessageId) { $responseWrapper['conversations'][0]['id'] = $sentMessageId; } $this->saveOutgoingMessage($data, $responseWrapper); } return $response; } /** * Realizar petición HTTP */ private function makeRequest($method, $url, $data = null) { $ch = curl_init(); $headers = [ 'Authorization: Bearer ' . $this->token, 'Content-Type: application/json', 'User-Agent: WhatsApp-Bot/1.0' ]; curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_HEADER => true, // Capturar headers de respuesta CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // Forzar TLS 1.2: evita el bug errno-56 de OpenSSL 3.x con Meta/Facebook CDN CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, ]); if ($method === 'POST' && $data) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); } $fullResponse = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $error = curl_error($ch); // Separar headers y body $headerText = substr($fullResponse, 0, $headerSize); $response = substr($fullResponse, $headerSize); // curl_close is deprecated in PHP 8.5+ (has no effect). Avoid calling it on newer versions. if (version_compare(PHP_VERSION, '8.5.0', '<')) { curl_close($ch); } // Capturar rate limit info de los headers if ($this->rateLimitMonitor && $headerText) { $endpoint = parse_url($url, PHP_URL_PATH); // Parsear headers string a array $headersArray = array_filter(explode("\r\n", $headerText)); $this->rateLimitMonitor->captureFromHeaders($headersArray, $endpoint); } if ($error) { error_log("cURL Error: " . $error); throw new Exception("Error de comunicación con WhatsApp: " . $error); } $decoded = json_decode($response, true); if ($httpCode >= 400) { $errorMsg = isset($decoded['error']['message']) ? $decoded['error']['message'] : 'Error desconocido'; // Debug detallado error_log("WhatsApp API Error Details:"); error_log("- URL: " . $url); error_log("- HTTP Code: " . $httpCode); error_log("- Response: " . $response); error_log("- Token (first 20 chars): " . substr($this->token, 0, 20) . "..."); throw new Exception("Error de WhatsApp API: " . $errorMsg); } return $decoded; } /** * Formatear número de teléfono */ private function formatPhoneNumber($phone) { // Remover caracteres especiales $phone = preg_replace('/[^0-9]/', '', $phone); // Si empieza con +57 (Colombia), mantenerlo if (substr($phone, 0, 2) === '57' && strlen($phone) >= 12) { return $phone; } // Si es número colombiano sin código de país if (strlen($phone) === 10 && substr($phone, 0, 1) === '3') { return '57' . $phone; } return $phone; } /** * Guardar mensaje enviado en BD */ private function saveOutgoingMessage($data, $response) { try { $user = $this->getUserByPhone($data['to']); if ($user) { $appMeta = $data['__app_meta'] ?? []; $messageData = [ 'user_id' => $user['id'], 'message_id' => $response['conversations'][0]['id'], 'direction' => 'outgoing', 'message_type' => $data['type'], 'content' => $this->extractMessageContent($data), 'status' => 'sent', 'created_at' => date('Y-m-d H:i:s'), 'canal' => $appMeta['canal'] ?? 'bot', ]; // Para mensajes multimedia, guardar el media_id si está disponible if (in_array($data['type'], ['image', 'video', 'audio', 'document'])) { $mediaId = $data[$data['type']]['id'] ?? null; $mediaLink = $data[$data['type']]['link'] ?? null; // Prioridad: usar media_id de WhatsApp, sino link if ($mediaId) { $messageData['media_url'] = $mediaId; $messageData['whatsapp_media_id'] = $mediaId; } elseif ($mediaLink) { $messageData['media_url'] = $mediaLink; } // Asegurar que content tenga el filename si está disponible $filename = $data[$data['type']]['filename'] ?? null; if ($filename) { // Si content está vacío o es una descripción genérica, usar filename if (empty($messageData['content']) || in_array($messageData['content'], ['[Imagen]', '[Video]', '[Audio]', '[Documento]'])) { $messageData['content'] = $filename; } } } // Log detallado para multimedia if (in_array($data['type'], ['image', 'video', 'audio', 'document'])) { error_log("WhatsAppService.saveOutgoingMessage - Multimedia:"); error_log(" - type: " . $data['type']); error_log(" - content: " . ($messageData['content'] ?? 'empty')); error_log(" - media_url: " . ($messageData['media_url'] ?? 'empty')); error_log(" - whatsapp_media_id: " . ($messageData['whatsapp_media_id'] ?? 'empty')); } // Si es reply (context) if (isset($data['context']['message_id'])) { // WhatsApp message IDs can be alphanumeric (e.g., wamid...), store as string $messageData['reply_to_message_id'] = trim((string)$data['context']['message_id']) ?: null; } // Si es reaction if ($data['type'] === 'reaction' && isset($data['reaction'])) { $messageData['reaction_to_message_id'] = isset($data['reaction']['message_id']) ? trim((string)$data['reaction']['message_id']) : null; $messageData['reaction_emoji'] = $data['reaction']['emoji'] ?? null; } $this->db->insert('conversations', $messageData); // Solo limpiar advisor_requested si el mensaje fue enviado por un asesor (operator) try { $cleared = false; if (isset($data['__app_meta']) && is_array($data['__app_meta']) && !empty($data['__app_meta']['operator_id'])) { $this->db->update('users', ['advisor_requested' => 0, 'on_hold' => 0, 'bot_paused_until' => null], 'id = :id', ['id' => $user['id']]); $cleared = true; } if ($cleared && function_exists('writeLog')) writeLog('INFO', "Cleared advisor_requested for user {$user['id']} after outgoing message by operator"); } catch (Exception $e) { error_log('Failed to clear advisor_requested after outgoing: ' . $e->getMessage()); } } } catch (Exception $e) { error_log("Error saving outgoing message: " . $e->getMessage()); } } /** * Extraer contenido del mensaje para guardar */ private function extractMessageContent($data) { switch ($data['type']) { case 'text': return $data['text']['body']; case 'template': return 'Template: ' . $data['template']['name']; case 'interactive': if (isset($data['interactive']['body']['text'])) { return $data['interactive']['body']['text']; } break; case 'reaction': return isset($data['reaction']['emoji']) ? $data['reaction']['emoji'] : ''; case 'image': // Prioridad: caption, filename, descripción por defecto if (!empty($data['image']['caption'])) { return $data['image']['caption']; } if (!empty($data['image']['filename'])) { return $data['image']['filename']; } return '[Imagen]'; case 'video': if (!empty($data['video']['caption'])) { return $data['video']['caption']; } if (!empty($data['video']['filename'])) { return $data['video']['filename']; } return '[Video]'; case 'audio': if (!empty($data['audio']['filename'])) { return $data['audio']['filename']; } if (!empty($data['audio']['caption'])) { return $data['audio']['caption']; } return '[Audio]'; case 'document': // Prioridad: filename, caption, descripción por defecto if (!empty($data['document']['filename'])) { return $data['document']['filename']; } if (!empty($data['document']['caption'])) { return $data['document']['caption']; } return '[Documento]'; } return ''; } /** * Enviar reacción (emoji) a un mensaje previo * @param string $to Teléfono del destinatario * @param string $messageId ID del mensaje al que se reacciona * @param string $emoji Emoji de reacción * @param bool $dryRun Si true, no hace la petición y devuelve el payload */ public function sendReaction($to, $messageId, $emoji, $dryRun = false) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'reaction', 'reaction' => [ 'message_id' => $messageId, 'emoji' => $emoji ] ]; if ($dryRun) return $data; return $this->sendMessage($data); } /** * Enviar texto como respuesta (con contexto) a un mensaje existente * @param string $to Teléfono del destinatario * @param string $messageId ID del mensaje al que se responde * @param string $text Contenido del mensaje de respuesta * @param bool $preview_url Incluir preview_url en body.text * @param bool $dryRun Si true, no hace la petición y devuelve el payload */ public function sendTextReply($to, $messageId, $text, $preview_url = false, $dryRun = false, $meta = null) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'text', 'context' => [ 'message_id' => $messageId ], 'text' => [ 'preview_url' => (bool)$preview_url, 'body' => $text ] ]; if (!empty($meta) && is_array($meta)) { $data['__app_meta'] = $meta; } if ($dryRun) return $data; return $this->sendMessage($data); } /** * Obtener usuario por teléfono */ private function getUserByPhone($phone) { return $this->db->fetch( "SELECT * FROM users WHERE phone_number = :phone", ['phone' => $phone] ); } /** * Descargar archivo multimedia */ public function downloadMedia($mediaId) { // Validar que mediaId no esté vacío ANTES de hacer la llamada if (empty($mediaId) || !is_string($mediaId) || trim($mediaId) === '') { error_log("downloadMedia: mediaId inválido o vacío: " . var_export($mediaId, true)); throw new Exception("Media ID no puede estar vacío"); } $url = $this->apiUrl . $mediaId; // Primero obtener información del archivo $mediaInfo = $this->makeRequest('GET', $url); if (!$mediaInfo || !isset($mediaInfo['url'])) { throw new Exception("No se pudo obtener información del archivo"); } // Descargar el archivo $fileUrl = $mediaInfo['url']; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $fileUrl, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $this->token ], CURLOPT_TIMEOUT => 60, CURLOPT_FOLLOWLOCATION => true, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, ]); $fileContent = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new Exception("Error descargando archivo multimedia"); } return [ 'content' => $fileContent, 'mime_type' => $mediaInfo['mime_type'] ?? 'application/octet-stream', 'file_size' => $mediaInfo['file_size'] ?? strlen($fileContent) ]; } }