gets Graph API URL and downloads * - storeContent($bytes, $destPath) * - createThumbnail($srcPath, $thumbPath) */ class MediaService { private $token; private $apiUrl; private $uploadsDir; public function __construct() { // Usar constante WHATSAPP_TOKEN (que ya tiene fallback a env var) si getConfigFromDB retorna vacío $dbToken = getConfigFromDB('whatsapp_token', ''); $this->token = !empty($dbToken) ? $dbToken : (defined('WHATSAPP_TOKEN') ? WHATSAPP_TOKEN : getenv('WHATSAPP_TOKEN')); $dbApiUrl = getConfigFromDB('whatsapp_api_url', ''); $this->apiUrl = rtrim(!empty($dbApiUrl) ? $dbApiUrl : (defined('WHATSAPP_API_URL') ? WHATSAPP_API_URL : 'https://graph.facebook.com/v22.0/'), '/'); $this->uploadsDir = __DIR__ . '/../uploads/media'; if (!is_dir($this->uploadsDir)) mkdir($this->uploadsDir, 0755, true); } /** * Hacer petición GET al Graph API con fallback si curl falla */ private function graphApiRequest($url) { // Intentar con curl primero $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token], CURLOPT_TIMEOUT => 15, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, ]); $resp = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if (!$err && $http >= 200 && $http < 400 && $resp) { return $resp; } // Fallback: file_get_contents con stream context error_log("[MediaService::graphApiRequest] curl failed (http={$http} err={$err}), trying file_get_contents..."); return $this->fileGetContentsWithAuth($url); } public function fetchAndStoreFromGraph($mediaId, $subdir = '') { // Obtener metadata (url) via Graph API $endpoint = "{$this->apiUrl}/{$mediaId}"; $resp = $this->graphApiRequest($endpoint); if ($resp === false) { throw new Exception('Graph API fetch failed for media ' . $mediaId); } $decoded = json_decode($resp, true); $mediaUrl = $decoded['url'] ?? ($decoded['data'][0]['url'] ?? null); if (!$mediaUrl) throw new Exception('No media url from Graph'); // Para URLs de lookaside.fbsbx.com, usar wget directamente porque curl/OpenSSL falla con ese CDN // Obtener URL fresca justo antes de descargar para evitar expiración $content = false; if (strpos($mediaUrl, 'lookaside.fbsbx.com') !== false) { $authHeaders = !empty($this->token) ? ['Authorization: Bearer ' . $this->token] : []; // Intentar wget directo con URL actual $content = $this->shellDownload($mediaUrl, $authHeaders); if ($content === false) { // URL puede haber expirado — obtener URL fresca y reintentar error_log("[MediaService] wget failed, getting fresh URL for {$mediaId}..."); $resp2 = $this->graphApiRequest($endpoint); if ($resp2) { $decoded2 = json_decode($resp2, true); $freshUrl = $decoded2['url'] ?? $mediaUrl; $content = $this->shellDownload($freshUrl, $authHeaders); } } } // Fallback al cascade completo (curl → file_get_contents → wget) para otras URLs if ($content === false) { $content = $this->downloadUrl($mediaUrl); if ($content === false) { // Obtener URL fresca y reintentar error_log("[MediaService] downloadUrl failed, getting fresh URL..."); $resp2 = $this->graphApiRequest($endpoint); if ($resp2) { $decoded2 = json_decode($resp2, true); $freshUrl = $decoded2['url'] ?? $mediaUrl; $content = $this->downloadUrl($freshUrl); } } } if ($content === false) throw new Exception('Failed to download media content from ' . $mediaUrl); // Detectar extension desde mime o url $mime = $decoded['mime_type'] ?? null; $ext = $this->extensionFromMime($mime) ?: pathinfo(parse_url($mediaUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'bin'; $dir = $this->uploadsDir . ($subdir ? '/' . trim($subdir, '/') : ''); if (!is_dir($dir)) mkdir($dir, 0755, true); $filename = uniqid('m_') . '.' . $ext; $fullPath = $dir . '/' . $filename; file_put_contents($fullPath, $content); $result = ['local_file' => 'uploads/media' . ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename, 'mime_type' => $mime, 'size' => filesize($fullPath)]; // Si es imagen, generar miniatura if ($this->isImageMime($mime) || preg_match('#^jpe?g|png|gif$#i', $ext)) { $thumbPath = $dir . '/thumb_' . $filename . '.jpg'; $thumbRel = dirname($result['local_file']) . '/thumb_' . $filename . '.jpg'; if ($this->createThumbnail($fullPath, $thumbPath)) { $result['local_thumb'] = $thumbRel; } } return $result; } // Nuevo: descargar y almacenar desde una URL directa public function fetchAndStoreFromUrl($url, $subdir = '') { $content = $this->downloadUrl($url); if ($content === false) { $content = $this->downloadUrl($url, ['Referer: https://graph.facebook.com']); } if ($content === false) throw new Exception('Failed to download media content from url ' . $url); // intentar inferir mime $mime = null; $ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'bin'; $dir = $this->uploadsDir . ($subdir ? '/' . trim($subdir, '/') : ''); if (!is_dir($dir)) mkdir($dir, 0755, true); $filename = uniqid('m_') . '.' . $ext; $fullPath = $dir . '/' . $filename; file_put_contents($fullPath, $content); $result = ['local_file' => 'uploads/media' . ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename, 'mime_type' => $mime, 'size' => filesize($fullPath)]; // Si parece imagen, intentar crear thumb if (preg_match('#jpe?g|png|gif#i', $ext)) { $thumbPath = $dir . '/thumb_' . $filename . '.jpg'; $thumbRel = dirname($result['local_file']) . '/thumb_' . $filename . '.jpg'; if ($this->createThumbnail($fullPath, $thumbPath)) { $result['local_thumb'] = $thumbRel; } } return $result; } public function downloadUrl($url, $extraHeaders = []) { // Incluir Authorization Bearer si está configurado (necesario para algunos endpoints) $authHeader = []; if (!empty($this->token)) { $authHeader[] = 'Authorization: Bearer ' . $this->token; } $headers = array_merge(['User-Agent: WhatsAppMediaFetcher/1.0', 'Accept: */*'], $authHeader, $extraHeaders); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 180, CURLOPT_HTTPHEADER => $headers, CURLOPT_BUFFERSIZE => 65536, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, ]); $content = curl_exec($ch); $err = curl_error($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $errno = curl_errno($ch); curl_close($ch); // Si curl funcionó correctamente, retornar contenido if (!$err && $http >= 200 && $http < 400 && $content !== false && strlen($content) > 0) { return $content; } // Fallback: file_get_contents con stream context (usa implementación SSL diferente a curl) error_log("[MediaService::downloadUrl] curl failed (http={$http} errno={$errno} err={$err}), trying file_get_contents fallback..."); $content = $this->fileGetContentsWithAuth($url); if ($content !== false && strlen($content) > 0) { error_log("[MediaService::downloadUrl] file_get_contents OK, " . strlen($content) . " bytes"); return $content; } // Fallback: exec wget (solo funciona en CLI, no en php-fpm con exec deshabilitado) $content = $this->shellDownload($url, $headers); if ($content !== false) { return $content; } error_log("[MediaService::downloadUrl] FAIL url=" . substr($url, 0, 120) . " http={$http} err={$err} all_fallbacks_failed"); return false; } /** * Descargar usando file_get_contents con stream context (usa implementación SSL nativa de PHP, diferente a curl) */ private function fileGetContentsWithAuth($url) { $opts = [ 'http' => [ 'method' => 'GET', 'header' => "Authorization: Bearer {$this->token}\r\nUser-Agent: WhatsAppMediaFetcher/1.0\r\nAccept: */*\r\n", 'follow_location' => true, 'max_redirects' => 5, 'timeout' => 120, 'ignore_errors' => false, ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]; $ctx = stream_context_create($opts); $content = @file_get_contents($url, false, $ctx); if ($content === false) { $lastErr = error_get_last(); error_log("[MediaService::fileGetContentsWithAuth] FAIL: " . ($lastErr['message'] ?? 'unknown')); return false; } return $content; } /** * Fallback: descargar con wget via exec/proc_open (solo funciona en CLI) */ private function shellDownload($url, $headers = []) { // Verificar si exec está realmente disponible // php_admin_value[disable_functions] NO aparece en ini_get('disable_functions') // así que hacemos un test real con try/catch $execAvailable = function_exists('exec'); if ($execAvailable) { try { @exec('echo 1', $testOut, $testRc); } catch (\Error $e) { $execAvailable = false; } } if (!$execAvailable) { return $this->procOpenDownload($url, $headers); } $tmpFile = tempnam(sys_get_temp_dir(), 'wget_'); // Solo pasar Authorization header para evitar problemas con User-Agent personalizado en CDN $headerArgs = ''; foreach ($headers as $h) { if (stripos($h, 'Authorization:') === 0) { $headerArgs .= ' --header=' . escapeshellarg($h); } } // --continue: reanuda desde donde se cortó (evita reiniciar ante Connection reset by peer) // --tries=10 --waitretry=3: reintenta hasta 10 veces esperando 3s entre intentos // --prefer-family=IPv4: evitar problemas IPv6 $cmd = 'wget --continue --tries=10 --waitretry=3 --prefer-family=IPv4 -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url) . ' 2>&1'; try { exec($cmd, $output, $exitCode); } catch (\Error $e) { error_log("[MediaService::shellDownload] exec() threw Error: " . $e->getMessage()); if (file_exists($tmpFile)) unlink($tmpFile); return $this->procOpenDownload($url, $headers); } $fileSize = (file_exists($tmpFile) ? filesize($tmpFile) : 0); error_log("[MediaService::shellDownload] wget exit={$exitCode} size={$fileSize} output=" . implode(' | ', array_slice($output, -3))); if ($exitCode === 0 && $fileSize > 0) { $content = file_get_contents($tmpFile); unlink($tmpFile); error_log("[MediaService::shellDownload] wget OK, " . strlen($content) . " bytes"); return $content; } error_log("[MediaService::shellDownload] wget FAIL exit={$exitCode} size={$fileSize}"); if (file_exists($tmpFile)) unlink($tmpFile); return false; } /** * Descargar con proc_open (alternativa a exec) */ private function procOpenDownload($url, $headers = []) { // Test real de disponibilidad: php_admin_value no se refleja en ini_get $procAvailable = function_exists('proc_open'); if ($procAvailable) { try { $testProc = @proc_open('echo 1', [1 => ['pipe', 'w']], $testPipes); if (is_resource($testProc)) { fclose($testPipes[1]); proc_close($testProc); } else { $procAvailable = false; } } catch (\Error $e) { $procAvailable = false; } } if (!$procAvailable) { error_log("[MediaService::procOpenDownload] proc_open not available, skipping"); return false; } $tmpFile = tempnam(sys_get_temp_dir(), 'wget_'); $headerArgs = ''; foreach ($headers as $h) { $headerArgs .= ' --header=' . escapeshellarg($h); } $cmd = 'wget -q --prefer-family=IPv4 -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url); try { $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); } catch (\Error $e) { error_log("[MediaService::procOpenDownload] proc_open() threw Error: " . $e->getMessage()); if (file_exists($tmpFile)) unlink($tmpFile); return false; } if (!is_resource($proc)) { error_log("[MediaService::procOpenDownload] proc_open failed"); if (file_exists($tmpFile)) unlink($tmpFile); return false; } $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]); $exitCode = proc_close($proc); if ($exitCode === 0 && file_exists($tmpFile) && filesize($tmpFile) > 0) { $content = file_get_contents($tmpFile); unlink($tmpFile); error_log("[MediaService::procOpenDownload] OK, " . strlen($content) . " bytes"); return $content; } error_log("[MediaService::procOpenDownload] FAIL exit={$exitCode} err={$stderr}"); if (file_exists($tmpFile)) unlink($tmpFile); return false; } public function extensionFromMime($mime) { if (!$mime) return null; // Remover parámetros del mime type (ej: "audio/ogg; codecs=opus" -> "audio/ogg") $baseMime = strtolower(trim(explode(';', $mime)[0])); $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/svg+xml' => 'svg', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/opus' => 'ogg', 'audio/aac' => 'aac', 'audio/amr' => 'amr', 'audio/mp4' => 'm4a', 'video/mp4' => 'mp4', 'video/3gpp' => '3gp', 'video/quicktime' => 'mov', 'video/webm' => 'webm', 'text/csv' => 'csv', 'text/plain' => 'txt', 'application/pdf' => 'pdf', 'application/msword' => 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', 'application/vnd.ms-excel' => 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', 'application/vnd.ms-powerpoint' => 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', 'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/octet-stream' => 'bin', ]; return $map[$baseMime] ?? null; } public function isImageMime($mime) { return strpos($mime, 'image/') === 0; } public function createThumbnail($src, $dest, $maxW = 400, $maxH = 400) { if (!function_exists('getimagesize')) return false; $info = @getimagesize($src); if (!$info) return false; list($w, $h) = $info; $mime = $info['mime']; switch ($mime) { case 'image/jpeg': $srcImg = imagecreatefromjpeg($src); break; case 'image/png': $srcImg = imagecreatefrompng($src); break; case 'image/gif': $srcImg = imagecreatefromgif($src); break; default: return false; } $ratio = min($maxW / $w, $maxH / $h, 1); $nw = (int)($w * $ratio); $nh = (int)($h * $ratio); $thumb = imagecreatetruecolor($nw, $nh); imagecopyresampled($thumb, $srcImg, 0,0,0,0, $nw, $nh, $w, $h); imagejpeg($thumb, $dest, 85); imagedestroy($thumb); imagedestroy($srcImg); return true; } }