From 0aa450be6785a6f97a948d5ee805d01a11b0787b Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:33:56 -0500 Subject: [PATCH] up --- Dockerfile | 7 +- conversations.php | 33 ++--- docker/php/php.ini | 4 + services/WhatsAppService.php | 268 +++++++++++++++++++++-------------- 4 files changed, 183 insertions(+), 129 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0fdad12..2de0250 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,9 +69,14 @@ RUN apk add --no-cache \ redis \ ffmpeg \ openssl \ + ca-certificates \ tzdata \ && cp /usr/share/zoneinfo/America/Bogota /etc/localtime \ - && echo "America/Bogota" > /etc/timezone + && echo "America/Bogota" > /etc/timezone \ + && update-ca-certificates \ + # Parche OpenSSL: deshabilitar session tickets TLS 1.3 + # Causa raíz del errno-56 'Connection reset by peer' con Meta/Facebook CDN + && printf '\n[openssl_init]\nssl_conf = ssl_sect\n\n[ssl_sect]\nsystem_default = system_default_sect\n\n[system_default_sect]\nOptions = -SessionTicket\n' >> /etc/ssl/openssl.cnf # Copiar extensiones PHP desde builder COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ diff --git a/conversations.php b/conversations.php index f236d6b..02403c7 100644 --- a/conversations.php +++ b/conversations.php @@ -2692,9 +2692,8 @@ if (!isset($_SESSION['user_id'])) { try { const prevScrollTop = container.scrollTop; const prevScrollHeight = container.scrollHeight; - // Only reset to top if literally at position 0 (or within one pixel) const wasAtTop = prevScrollTop <= 4; - + container.innerHTML = html; void container.offsetHeight; // forzar repaint @@ -2704,29 +2703,18 @@ if (!isset($_SESSION['user_id'])) { if (wasAtTop) { container.scrollTop = 0; } else { - // Preservar posición visual exacta compensando cambio de altura - const restoredPos = Math.max(0, prevScrollTop + scrollDelta); - container.scrollTop = restoredPos; - // Si el ítem activo está ahora fuera de vista (más de 2 alturas de pantalla), - // acercarlo de forma suave sin regresar al tope - const activeItem = container.querySelector('.conversation-item.active'); - if (activeItem) { - const rect = activeItem.getBoundingClientRect(); - const listRect = container.getBoundingClientRect(); - const isVisible = rect.top >= listRect.top && rect.bottom <= listRect.bottom; - if (!isVisible) { - activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - } + // Restaurar posición visual exacta compensando cambio de altura del contenido + container.scrollTop = Math.max(0, prevScrollTop + scrollDelta); + // NOTA: NO llamar scrollIntoView aquí — se ejecuta en cada update SSE + // y causa que el scroll baje solo continuamente hasta el fondo. + // scrollIntoView solo se llama manualmente al abrir una conversación. } - + console.log('✅ renderConversations COMPLETADO'); } catch (e) { - // fallback to naive replace if anything failed console.warn('renderConversations: scroll preservation failed', e); container.innerHTML = html; void container.offsetHeight; - console.log('⚠️ renderConversations COMPLETADO con fallback'); } } @@ -2848,7 +2836,12 @@ if (!isset($_SESSION['user_id'])) { document.querySelectorAll('.conversation-item').forEach(item => { item.classList.remove('active'); }); - document.querySelector(`[data-user-id="${userId}"]`)?.classList.add('active'); + const activeItem = document.querySelector(`[data-user-id="${userId}"]`); + if (activeItem) { + activeItem.classList.add('active'); + // Scroll al ítem activo solo al abrirlo (no en cada re-render) + activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } // Actualizar estado del toggle del bot según datos de la conversación (ya obtuvimos conv arriba) if (conv) { diff --git a/docker/php/php.ini b/docker/php/php.ini index a35ee3d..b7e0384 100644 --- a/docker/php/php.ini +++ b/docker/php/php.ini @@ -22,6 +22,10 @@ allow_url_fopen = On allow_url_include = Off disable_functions = +; cURL - usar CA bundle del sistema (Alpine actualizado) +[curl] +curl.cainfo = /etc/ssl/certs/ca-certificates.crt + ; Session session.save_handler = redis session.save_path = "tcp://redis:6379" diff --git a/services/WhatsAppService.php b/services/WhatsAppService.php index 12eede0..e6a2f71 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 usando el CLI curl vía exec (mismo workaround que para descargas). + * reintenta con distintas opciones SSL y usa proc_open como fallback final. */ public function uploadMedia($filePath, $mimeType) { @@ -432,113 +432,162 @@ class WhatsAppService $url = $this->apiUrl . $this->phoneNumberId . '/media'; - // ── Intento 1: PHP curl con opciones SSL relajadas ────────────────────── - $ch = curl_init(); - - $postFields = [ - 'messaging_product' => 'whatsapp', - 'file' => new CURLFile($filePath, $mimeType), - 'type' => $mimeType - ]; - - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postFields, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $this->token - ], - CURLOPT_TIMEOUT => 300, - CURLOPT_CONNECTTIMEOUT => 30, - CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - // Forzar TLS 1.2 (evita problemas de negociación SSL en algunos VPS) - CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, + // ── 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, + CURLOPT_SSL_ENABLE_ALPN => false, + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, ]); + if ($attempt1['ok']) return $attempt1['result']; + + 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, + ]); + if ($attempt2['ok']) return $attempt2['result']; + + error_log("WhatsAppService::uploadMedia attempt2 fail: errno={$attempt2['errno']} err={$attempt2['error']} http={$attempt2['http']}"); + + // ── 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 + if (function_exists('proc_open')) { + try { + $result = $this->_procOpenUpload($url, $filePath, $mimeType); + if ($result) return $result; + } catch (Exception $e) { + 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'." + ); + } + + /** + * Helper interno: un intento de upload con PHP curl con opciones extras. + */ + private function _curlUploadAttempt($url, $filePath, $mimeType, array $extraOpts = []) + { + $ch = curl_init(); + $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], + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + ]; + curl_setopt_array($ch, $base + $extraOpts); $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - $curlErrno = curl_errno($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + $errno = curl_errno($ch); curl_close($ch); - error_log("WhatsAppService::uploadMedia (php-curl) - URL: {$url}, HTTP: {$httpCode}, errno: {$curlErrno}, error: {$curlError}"); + 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 in response: ' . substr($response, 0, 200), 'http' => $http]; + } + return ['ok' => true, 'result' => $result]; + } - // Errores de capa SSL/red que se benefician del fallback CLI - $sslErrors = [35, 56, 77, 58, 59, 60]; // CURLE_SSL_CONNECT_ERROR, CURLE_RECV_ERROR, etc. - $needsFallback = ($curlErrno !== 0 && in_array($curlErrno, $sslErrors, true)); - - if (!$needsFallback && $curlErrno !== 0) { - throw new Exception("Error de conexión subiendo archivo (curl #{$curlErrno}): {$curlError}"); + /** + * Helper interno: upload usando proc_open + curl CLI. + * proc_open hereda el entorno de red completo, más confiable que exec() en PHP-FPM. + */ + private function _procOpenUpload($url, $filePath, $mimeType) + { + // Buscar curl binary + $curlBin = '/usr/bin/curl'; + if (!file_exists($curlBin)) { + $curlBin = trim(shell_exec('which curl 2>/dev/null') ?: ''); + } + if (empty($curlBin) || !file_exists($curlBin)) { + throw new Exception("curl CLI no encontrado"); } - if (!$needsFallback) { - if ($httpCode !== 200 && $httpCode !== 201) { - throw new Exception("Error subiendo archivo (HTTP {$httpCode}): " . $response); - } - $result = json_decode($response, true); - if (!isset($result['id'])) { - throw new Exception("No se obtuvo ID del archivo subido: " . $response); - } - return $result; + $tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_'); + + // Construir argumentos sin shell_exec (array, evita problemas de escape) + $args = [ + $curlBin, + '--silent', '--show-error', + '--tlsv1.2', + '--max-time', '300', + '-o', $tmpResponse, + '-w', '%{http_code}', + '-X', 'POST', + '-H', 'Authorization: Bearer ' . $this->token, + '-F', 'messaging_product=whatsapp', + '-F', 'type=' . $mimeType, + '-F', 'file=@' . $filePath . ';type=' . $mimeType, + $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']); + $proc = proc_open($cmd, $descriptors, $pipes, null, $env); + + if (!is_resource($proc)) { + @unlink($tmpResponse); + throw new Exception("proc_open falló"); } - // ── Intento 2: CLI curl (usa GnuTLS / sistema, evita bug OpenSSL de PHP) ─ - error_log("WhatsAppService::uploadMedia: PHP curl falló (errno {$curlErrno}), usando curl CLI..."); + 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); - if (!function_exists('exec') || !@exec('echo 1')) { - throw new Exception("Error de conexión subiendo archivo (curl #{$curlErrno}): {$curlError} — fallback CLI no disponible"); - } - - // Archivo temporal para la respuesta del CLI - $tmpResponse = tempnam(sys_get_temp_dir(), 'wa_upload_'); - - $cmd = sprintf( - 'curl --silent --show-error' - . ' --ipv4' - . ' --tlsv1.2' - . ' --max-time 300' - . ' -o %s' - . ' -w "%%{http_code}"' - . ' -X POST' - . ' -H %s' - . ' -F %s' - . ' -F %s' - . ' -F %s' - . ' %s' - . ' 2>&1', - escapeshellarg($tmpResponse), - escapeshellarg('Authorization: Bearer ' . $this->token), - escapeshellarg('messaging_product=whatsapp'), - escapeshellarg('type=' . $mimeType), - escapeshellarg('file=@' . $filePath . ';type=' . $mimeType), - escapeshellarg($url) - ); - - $cliHttpCode = null; - $cliOut = null; - @exec($cmd, $outputLines, $exitCode); - $cliHttpCode = trim(implode('', $outputLines)); - $cliBody = @file_get_contents($tmpResponse); + $httpCode = intval(trim($stdout)); + $body = @file_get_contents($tmpResponse); @unlink($tmpResponse); - error_log("WhatsAppService::uploadMedia (curl-cli) - exit: {$exitCode}, http: {$cliHttpCode}, body: " . substr((string)$cliBody, 0, 300)); + error_log("WhatsAppService::_procOpenUpload exit={$exitCode} http={$httpCode} stderr=" . substr($stderr, 0, 200)); if ($exitCode !== 0) { - throw new Exception("Error de conexión subiendo archivo (curl CLI exit {$exitCode}): {$cliHttpCode}"); + throw new Exception("curl CLI exit {$exitCode}: " . trim($stderr)); } - - $cliCode = intval($cliHttpCode); - if ($cliCode !== 200 && $cliCode !== 201) { - throw new Exception("Error subiendo archivo via CLI (HTTP {$cliCode}): " . $cliBody); + if ($httpCode !== 200 && $httpCode !== 201) { + throw new Exception("HTTP {$httpCode}: " . substr($body, 0, 300)); } - - $result = json_decode($cliBody, true); + $result = json_decode($body, true); if (!isset($result['id'])) { - throw new Exception("No se obtuvo ID del archivo subido (CLI): " . $cliBody); + throw new Exception("No ID en respuesta CLI: " . substr($body, 0, 300)); } - return $result; } @@ -649,18 +698,20 @@ class WhatsAppService ]; 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, + 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) { @@ -961,15 +1012,16 @@ class WhatsAppService $ch = curl_init(); curl_setopt_array($ch, [ - CURLOPT_URL => $fileUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ + 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_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);