This commit is contained in:
Lizandro Guarnizo
2026-02-21 11:33:56 -05:00
parent 5dd48ba5c2
commit 0aa450be67
4 changed files with 183 additions and 129 deletions
+6 -1
View File
@@ -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/
+13 -20
View File
@@ -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) {
+4
View File
@@ -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"
+160 -108
View File
@@ -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);