Update WhatsAppService.php

This commit is contained in:
Lizandro Guarnizo
2026-02-21 11:39:25 -05:00
parent 0aa450be67
commit e00c96c614
+121 -43
View File
@@ -422,7 +422,7 @@ class WhatsAppService
/** /**
* Subir archivo multimedia a WhatsApp * Subir archivo multimedia a WhatsApp
* Intenta primero con PHP curl. Si falla por SSL/conexión (errno 35, 56, 77), * 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) public function uploadMedia($filePath, $mimeType)
{ {
@@ -431,62 +431,63 @@ class WhatsAppService
} }
$url = $this->apiUrl . $this->phoneNumberId . '/media'; $url = $this->apiUrl . $this->phoneNumberId . '/media';
$errors = [];
// ── Intento 1: PHP curl TLS 1.2 + sin ALPN (fix Meta/Facebook errno 56) ─── // ── Intento 1: PHP curl TLS 1.2 + sin ALPN + IPv4 ──────────────────────
$attempt1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [ $a1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
CURLOPT_SSL_ENABLE_ALPN => false, 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 SSL deshabilitado + IPv4 (bypassa verificación) ──
$a2 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [
// ── 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_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false, CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
]); ]);
if ($attempt3['ok']) return $attempt3['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 attempt3 fail: errno={$attempt3['errno']} err={$attempt3['error']} http={$attempt3['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 4: proc_open con curl CLI (hereda entorno completo de red) ───── // ── Intento 4: proc_open + curl CLI con --ipv4 ──────────────────────────
// proc_open es más confiable que exec() para mantener env de red en PHP-FPM
if (function_exists('proc_open')) { if (function_exists('proc_open')) {
try { try {
$result = $this->_procOpenUpload($url, $filePath, $mimeType); $result = $this->_procOpenUpload($url, $filePath, $mimeType);
if ($result) return $result; if ($result) return $result;
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = "proc_open: " . $e->getMessage();
error_log("WhatsAppService::uploadMedia proc_open fail: " . $e->getMessage()); error_log("WhatsAppService::uploadMedia proc_open fail: " . $e->getMessage());
} }
} }
// Ningún método funcionó — lanzar error con contexto
throw new Exception( throw new Exception(
"Error al subir archivo a WhatsApp. " . "Error al subir archivo a WhatsApp tras 4 intentos: " . implode(' | ', $errors) .
"Intento1: curl#{$attempt1['errno']}({$attempt1['error']}). " . ". Solución permanente: docker compose up --build (aplica parche OpenSSL en contenedor)."
"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. * 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 = []) private function _curlUploadAttempt($url, $filePath, $mimeType, array $extraOpts = [])
{ {
$ch = curl_init(); $ch = curl_init();
// Base siempre incluye IPRESOLVE_V4; extraOpts puede sobreescribirlo si es necesario
$base = [ $base = [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -500,6 +501,7 @@ class WhatsAppService
CURLOPT_TIMEOUT => 300, CURLOPT_TIMEOUT => 300,
CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 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); curl_setopt_array($ch, $base + $extraOpts);
@@ -514,32 +516,108 @@ class WhatsAppService
} }
$result = json_decode($response, true); $result = json_decode($response, true);
if (!isset($result['id'])) { 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]; return ['ok' => true, 'result' => $result];
} }
/** /**
* Helper interno: upload usando proc_open + curl CLI. * Upload usando PHP stream_context (evita libcurl y su OpenSSL completamente).
* proc_open hereda el entorno de red completo, más confiable que exec() en PHP-FPM. * 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) private function _procOpenUpload($url, $filePath, $mimeType)
{ {
// Buscar curl binary // Localizar curl binary
$curlBin = '/usr/bin/curl'; $curlBin = '/usr/bin/curl';
if (!file_exists($curlBin)) { 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"); throw new Exception("curl CLI no encontrado");
} }
$tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_'); $tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_');
// Construir argumentos sin shell_exec (array, evita problemas de escape)
$args = [ $args = [
$curlBin, $curlBin,
'--silent', '--show-error', '--silent', '--show-error',
'--ipv4', // forzar IPv4 (evita errno 7 por IPv6 no ruteado)
'--tlsv1.2', '--tlsv1.2',
'--max-time', '300', '--max-time', '300',
'-o', $tmpResponse, '-o', $tmpResponse,
@@ -552,17 +630,17 @@ class WhatsAppService
$url, $url,
]; ];
// Construir comando como string para proc_open
$cmd = implode(' ', array_map('escapeshellarg', $args)); $cmd = implode(' ', array_map('escapeshellarg', $args));
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $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 = array_merge($_ENV ?: [], ['HOME' => '/tmp', 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']); $_ENV ?: [],
['HOME' => '/tmp', 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']
);
$proc = proc_open($cmd, $descriptors, $pipes, null, $env); $proc = proc_open($cmd, $descriptors, $pipes, null, $env);
if (!is_resource($proc)) { if (!is_resource($proc)) {
@unlink($tmpResponse); @unlink($tmpResponse);
throw new Exception("proc_open falló"); throw new Exception("proc_open no pudo iniciar el proceso");
} }
fclose($pipes[0]); fclose($pipes[0]);
@@ -576,17 +654,17 @@ class WhatsAppService
$body = @file_get_contents($tmpResponse); $body = @file_get_contents($tmpResponse);
@unlink($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) { if ($exitCode !== 0) {
throw new Exception("curl CLI exit {$exitCode}: " . trim($stderr)); throw new Exception("curl CLI exit {$exitCode}: " . trim($stderr));
} }
if ($httpCode !== 200 && $httpCode !== 201) { 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); $result = json_decode($body, true);
if (!isset($result['id'])) { 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; return $result;
} }