up
This commit is contained in:
+6
-1
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user