From e00c96c614544a3aa072043f1ae101c9977103a4 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:39:25 -0500 Subject: [PATCH] Update WhatsAppService.php --- services/WhatsAppService.php | 184 +++++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 53 deletions(-) diff --git a/services/WhatsAppService.php b/services/WhatsAppService.php index e6a2f71..d5e5e1f 100644 --- a/services/WhatsAppService.php +++ b/services/WhatsAppService.php @@ -422,7 +422,7 @@ class WhatsAppService /** * 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 proc_open como fallback final. + * reintenta con distintas opciones SSL y usa stream_context/proc_open como fallback. */ public function uploadMedia($filePath, $mimeType) { @@ -431,62 +431,63 @@ class WhatsAppService } $url = $this->apiUrl . $this->phoneNumberId . '/media'; + $errors = []; - // ── Intento 1: PHP curl TLS 1.2 + sin ALPN (fix Meta/Facebook errno 56) ─── - $attempt1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ - CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, + // ── Intento 1: PHP curl TLS 1.2 + sin ALPN + IPv4 ────────────────────── + $a1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ + CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, CURLOPT_SSL_ENABLE_ALPN => false, - CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, ]); - if ($attempt1['ok']) return $attempt1['result']; + if ($a1['ok']) return $a1['result']; + $errors[] = "curl1: #{$a1['errno']}({$a1['error']}) http={$a1['http']}"; + error_log("WhatsAppService::uploadMedia a1 fail: {$errors[0]}"); - error_log("WhatsAppService::uploadMedia attempt1 fail: errno={$attempt1['errno']} err={$attempt1['error']} http={$attempt1['http']}"); - - // ── Intento 2: PHP curl TLS 1.2 sin restricción de IP (por si IPv4 falla) ── - $attempt2 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ - CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, - CURLOPT_SSL_ENABLE_ALPN => false, + // ── Intento 2: PHP curl SSL deshabilitado + IPv4 (bypassa verificación) ── + $a2 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, ]); - if ($attempt2['ok']) return $attempt2['result']; + if ($a2['ok']) return $a2['result']; + $errors[] = "curl2: #{$a2['errno']}({$a2['error']}) http={$a2['http']}"; + error_log("WhatsAppService::uploadMedia a2 fail: {$errors[1]}"); - error_log("WhatsAppService::uploadMedia attempt2 fail: errno={$attempt2['errno']} err={$attempt2['error']} http={$attempt2['http']}"); + // ── 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 3: PHP curl sin restricciones SSL (TLS negociado automático) ─── - $attempt3 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - ]); - if ($attempt3['ok']) return $attempt3['result']; - - error_log("WhatsAppService::uploadMedia attempt3 fail: errno={$attempt3['errno']} err={$attempt3['error']} http={$attempt3['http']}"); - - // ── Intento 4: proc_open con curl CLI (hereda entorno completo de red) ───── - // proc_open es más confiable que exec() para mantener env de red en PHP-FPM + // ── 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()); } } - // Ningún método funcionó — lanzar error con contexto throw new Exception( - "Error al subir archivo a WhatsApp. " . - "Intento1: curl#{$attempt1['errno']}({$attempt1['error']}). " . - "Intento2: curl#{$attempt2['errno']}({$attempt2['error']}). " . - "Intento3: curl#{$attempt3['errno']}({$attempt3['error']}). " . - "Solución: reconstruir contenedor con 'docker compose up --build'." + "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: un intento de upload con PHP curl con opciones extras. + * 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, @@ -500,13 +501,14 @@ class WhatsAppService 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); + $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)) { @@ -514,32 +516,108 @@ class WhatsAppService } $result = json_decode($response, true); if (!isset($result['id'])) { - return ['ok' => false, 'errno' => 0, 'error' => 'No ID in response: ' . substr($response, 0, 200), 'http' => $http]; + return ['ok' => false, 'errno' => 0, 'error' => 'No ID: ' . substr($response, 0, 200), 'http' => $http]; } return ['ok' => true, 'result' => $result]; } /** - * Helper interno: upload usando proc_open + curl CLI. - * proc_open hereda el entorno de red completo, más confiable que exec() en PHP-FPM. + * 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) { - // Buscar curl binary + // Localizar curl binary $curlBin = '/usr/bin/curl'; if (!file_exists($curlBin)) { - $curlBin = trim(shell_exec('which curl 2>/dev/null') ?: ''); + foreach (['/usr/local/bin/curl', '/bin/curl'] as $c) { + if (file_exists($c)) { $curlBin = $c; break; } + } } - if (empty($curlBin) || !file_exists($curlBin)) { + if (!file_exists($curlBin)) { throw new Exception("curl CLI no encontrado"); } $tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_'); - // Construir argumentos sin shell_exec (array, evita problemas de escape) $args = [ $curlBin, '--silent', '--show-error', + '--ipv4', // forzar IPv4 (evita errno 7 por IPv6 no ruteado) '--tlsv1.2', '--max-time', '300', '-o', $tmpResponse, @@ -552,22 +630,22 @@ class WhatsAppService $url, ]; - // Construir comando como string para proc_open $cmd = implode(' ', array_map('escapeshellarg', $args)); - $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - // Heredar entorno completo del proceso actual (clave para que funcione DNS en PHP-FPM) - $env = array_merge($_ENV ?: [], ['HOME' => '/tmp', 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']); + $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 falló"); + 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]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($proc); @@ -576,17 +654,17 @@ class WhatsAppService $body = @file_get_contents($tmpResponse); @unlink($tmpResponse); - error_log("WhatsAppService::_procOpenUpload exit={$exitCode} http={$httpCode} stderr=" . substr($stderr, 0, 200)); + 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("HTTP {$httpCode}: " . substr($body, 0, 300)); + throw new Exception("CLI HTTP {$httpCode}: " . substr((string)$body, 0, 300)); } $result = json_decode($body, true); if (!isset($result['id'])) { - throw new Exception("No ID en respuesta CLI: " . substr($body, 0, 300)); + throw new Exception("CLI sin ID: " . substr((string)$body, 0, 300)); } return $result; }