From 2f73ee2a1a865ef3f4fbd1a8884d428d29e25ab7 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:46:29 -0500 Subject: [PATCH] up --- api/media_queue_status.php | 90 +++++++++ api/send_media_message.php | 26 ++- api/version/media-url.php | 362 ++++++++++++++++------------------- services/WhatsAppService.php | 3 +- 4 files changed, 273 insertions(+), 208 deletions(-) create mode 100644 api/media_queue_status.php diff --git a/api/media_queue_status.php b/api/media_queue_status.php new file mode 100644 index 0000000..a802716 --- /dev/null +++ b/api/media_queue_status.php @@ -0,0 +1,90 @@ +fetchAll("SELECT status, COUNT(*) as total FROM media_queue GROUP BY status"); + $result = ['pending' => 0, 'processing' => 0, 'done' => 0, 'failed' => 0, 'permanently_failed' => 0]; + foreach ($stats as $s) { + $result[$s['status']] = intval($s['total']); + } + $result['total'] = array_sum($result); + + // Últimos 5 errores + $lastErrors = $db->fetchAll("SELECT id, media_id, media_url, status, attempts, result, created_at FROM media_queue WHERE status IN ('failed','permanently_failed') ORDER BY created_at DESC LIMIT 5"); + + echo json_encode(['success' => true, 'stats' => $result, 'last_errors' => $lastErrors], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + break; + + case 'retry': + $id = intval($_GET['id'] ?? 0); + if (!$id) { + echo json_encode(['success' => false, 'error' => 'Se requiere id']); + exit; + } + $db->query("UPDATE media_queue SET status = 'pending', attempts = 0, result = NULL WHERE id = :id", ['id' => $id]); + echo json_encode(['success' => true, 'message' => "Item #{$id} marcado para reintento"]); + break; + + case 'retry_all': + $db->query("UPDATE media_queue SET status = 'pending', attempts = 0, result = NULL WHERE status IN ('failed', 'permanently_failed')"); + $affected = $db->fetch("SELECT ROW_COUNT() as cnt"); + echo json_encode(['success' => true, 'message' => "Todos los fallidos marcados para reintento"]); + break; + + case 'list': + default: + $status = $_GET['status'] ?? null; + $limit = min(intval($_GET['limit'] ?? 50), 200); + + $where = ''; + $params = []; + if ($status) { + $where = ' WHERE status = :status'; + $params['status'] = $status; + } + + $rows = $db->fetchAll( + "SELECT mq.*, c.message_type, c.filename as conv_filename, c.content as conv_content + FROM media_queue mq + LEFT JOIN conversations c ON c.id = mq.conversation_id + {$where} + ORDER BY mq.created_at DESC + LIMIT {$limit}", + $params + ); + + // Stats rápidas + $statsRaw = $db->fetchAll("SELECT status, COUNT(*) as total FROM media_queue GROUP BY status"); + $stats = []; + foreach ($statsRaw as $s) $stats[$s['status']] = intval($s['total']); + + echo json_encode([ + 'success' => true, + 'stats' => $stats, + 'count' => count($rows), + 'items' => $rows + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + break; + } +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/send_media_message.php b/api/send_media_message.php index af65737..6812b06 100644 --- a/api/send_media_message.php +++ b/api/send_media_message.php @@ -189,17 +189,33 @@ try { if ($isLocalUrl) { error_log("send_media_message.php - URL local detectada, subiendo archivo a WhatsApp primero"); + set_time_limit(300); // Tiempo extra para archivos grandes // Obtener la ruta local del archivo $parsedUrl = parse_url($mediaUrl); $relativePath = $parsedUrl['path']; - // Construir ruta absoluta - $uploadDir = __DIR__ . '/../uploads/'; - $filename_from_url = basename($relativePath); - $localFilePath = $uploadDir . $filename_from_url; + // Construir ruta absoluta usando el path completo (no solo basename) + $docRoot = __DIR__ . '/..'; + $localFilePath = $docRoot . $relativePath; - error_log("send_media_message.php - Ruta local del archivo: " . $localFilePath); + // Si no existe, intentar con solo el basename en uploads/ + if (!file_exists($localFilePath)) { + $uploadDir = __DIR__ . '/../uploads/'; + $localFilePath = $uploadDir . basename($relativePath); + } + + // Último intento: buscar recursivamente en uploads/ + if (!file_exists($localFilePath)) { + $searchName = basename($relativePath); + $found = glob(__DIR__ . '/../uploads/**/' . $searchName); + if (!empty($found)) { + $localFilePath = $found[0]; + } + } + + error_log("send_media_message.php - Ruta local del archivo: " . $localFilePath . " - existe: " . (file_exists($localFilePath) ? 'SI' : 'NO') . " - size: " . (file_exists($localFilePath) ? filesize($localFilePath) : 'N/A')); + smm_log("Local file: {$localFilePath} exists=" . (file_exists($localFilePath) ? 'yes' : 'no') . " size=" . (file_exists($localFilePath) ? filesize($localFilePath) : 'N/A')); if (!file_exists($localFilePath)) { throw new Exception('Archivo no encontrado en el servidor: ' . $localFilePath); diff --git a/api/version/media-url.php b/api/version/media-url.php index c340ea3..798a3f5 100644 --- a/api/version/media-url.php +++ b/api/version/media-url.php @@ -55,6 +55,123 @@ $log_write_failed = false; $resolvedFromGraph = false; $decoded = null; +/** + * Stream a remote file directly to the browser without buffering in memory. + * Solves timeout and memory issues with large videos/PDFs. + */ +function streamRemoteFile($url, $token = '', $forceDownload = false, $suggestedFilename = null) { + set_time_limit(300); + while (ob_get_level()) @ob_end_clean(); + + $curlHeaders = ['User-Agent: WhatsAppMediaProxy/1.0', 'Accept: */*']; + if ($token) $curlHeaders[] = 'Authorization: Bearer ' . $token; + + $respHeaders = []; + $browserHeadersSent = false; + $isError = false; + $errorBody = ''; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_BUFFERSIZE => 65536, + CURLOPT_HTTPHEADER => $curlHeaders, + CURLOPT_HEADERFUNCTION => function($ch, $headerLine) use (&$respHeaders) { + $len = strlen($headerLine); + $parts = explode(':', $headerLine, 2); + if (count($parts) == 2) { + $respHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + return $len; + }, + CURLOPT_WRITEFUNCTION => function($ch, $data) use (&$browserHeadersSent, &$respHeaders, &$isError, &$errorBody, $forceDownload, $suggestedFilename) { + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + // Buffer error responses + if ($httpCode >= 400) { + $isError = true; + $errorBody .= $data; + return strlen($data); + } + + // Detect JSON error responses + if (!$browserHeadersSent) { + $ct = $respHeaders['content-type'] ?? curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? ''; + if (stripos($ct, 'application/json') !== false) { + $isError = true; + $errorBody .= $data; + return strlen($data); + } + } + + // Send browser headers on first data chunk + if (!$browserHeadersSent) { + $browserHeadersSent = true; + $contentType = $respHeaders['content-type'] ?? curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'application/octet-stream'; + $contentLength = $respHeaders['content-length'] ?? null; + + header_remove('Content-Type'); + header('Cache-Control: public, max-age=86400'); + header('Content-Type: ' . $contentType); + if ($contentLength) header('Content-Length: ' . $contentLength); + + if ($forceDownload) { + $filename = $suggestedFilename; + if (!$filename) { + $cd = $respHeaders['content-disposition'] ?? null; + if ($cd) { + if (preg_match("/filename\\*=(?:[^']*'')?(.+)$/i", $cd, $m)) { + $filename = rawurldecode(trim($m[1], " \"'\r\n")); + } elseif (preg_match('/filename="?([^";]+)"?/i', $cd, $m)) { + $filename = trim($m[1]); + } + } + } + if (!$filename) { + $path = parse_url(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: '', PHP_URL_PATH) ?: ''; + $filename = basename($path) ?: 'download'; + if (strpos($filename, '.') === false) { + $mimeMap = ['application/pdf'=>'.pdf','image/jpeg'=>'.jpg','image/png'=>'.png','video/mp4'=>'.mp4','audio/mpeg'=>'.mp3','audio/ogg'=>'.ogg']; + $baseCt = strtolower(trim(explode(';', $contentType)[0])); + if (isset($mimeMap[$baseCt])) $filename .= $mimeMap[$baseCt]; + } + } + header('Content-Disposition: attachment; filename="' . $filename . '"'); + } + } + + echo $data; + flush(); + return strlen($data); + }, + ]); + + curl_exec($ch); + $err = curl_error($ch); + $finalHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($err || $isError) { + if (!$browserHeadersSent) { + http_response_code(502); + header('Content-Type: application/json; charset=utf-8'); + if ($isError && $errorBody) { + $decoded = json_decode($errorBody, true); + echo json_encode(['success' => false, 'error' => 'Remote server error', 'http_code' => $finalHttpCode, 'remote' => $decoded]); + } else { + echo json_encode(['success' => false, 'error' => 'Error streaming file', 'detail' => $err]); + } + } + media_log("streamRemoteFile failed: url={$url} err={$err} http={$finalHttpCode}"); + return false; + } + + return true; +} + // Log de petición inicial media_log("Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '') . " GET:" . json_encode($_GET) . " POST:" . json_encode($_POST)); @@ -102,6 +219,40 @@ if (!$mediaId && !$providedUrl) { } // ── PRIORIDAD: Buscar archivo local en BD antes de consultar Graph API ── +// También buscar por URL completa (para videos/docs enviados como URL lookaside) +if ($providedUrl) { + try { + $db = Database::getInstance(); + $row = $db->fetch( + "SELECT local_file, local_thumb, mime_type, filename FROM conversations WHERE media_url = :url AND local_file IS NOT NULL AND local_file != '' ORDER BY id DESC LIMIT 1", + ['url' => $providedUrl] + ); + if ($row && !empty($row['local_file'])) { + $localPath = realpath(__DIR__ . '/../../' . ltrim($row['local_file'], '/')); + $base = realpath(__DIR__ . '/../../uploads'); + if ($localPath && strpos($localPath, $base) === 0 && file_exists($localPath)) { + media_log("Serving local file for URL {$providedUrl}: {$localPath}"); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $ctype = finfo_file($finfo, $localPath) ?: ($row['mime_type'] ?? 'application/octet-stream'); + finfo_close($finfo); + + header_remove('Content-Type'); + header('Cache-Control: public, max-age=86400'); + header('Content-Type: ' . $ctype); + header('Content-Length: ' . filesize($localPath)); + if ($download) { + $fname = $row['filename'] ?: basename($localPath); + header('Content-Disposition: attachment; filename="' . $fname . '"'); + } + readfile($localPath); + exit; + } + } + } catch (Exception $e) { + media_log("Error buscando local_file por URL: " . $e->getMessage()); + } +} + if ($mediaId) { try { $db = Database::getInstance(); @@ -538,169 +689,19 @@ if ($download) { exit; } - // Si no es local, hacer curl normal (incluye Authorization si aplica) - $ch = curl_init(); - // Añadir Authorization si existe token en configuración + // Streaming proxy para descargas (soporta archivos grandes sin buffering en memoria) $token = getConfigFromDB('whatsapp_token', ''); - $headers = ['User-Agent: WhatsAppMediaProxy/1.0', 'Accept: */*']; - if ($token) $headers[] = 'Authorization: Bearer ' . $token; - - // Pedimos cabeceras + body para poder extraer Content-Disposition - curl_setopt_array($ch, [ - CURLOPT_URL => $mediaUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_HEADER => true, - ]); - $response = curl_exec($ch); - $info = curl_getinfo($ch); - $err = curl_error($ch); - curl_close($ch); - - if ($err) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Error al descargar el archivo', 'detail' => $err]); - exit; - } - - // Si el servidor remoto devolvió un código HTTP de error, devolverlo al cliente - $remoteHttp = isset($info['http_code']) ? (int)$info['http_code'] : 0; - if ($remoteHttp >= 400) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Remote server returned error status', 'http_code' => $remoteHttp, 'url' => $mediaUrl]); - exit; - } - - $headerSize = isset($info['header_size']) ? (int)$info['header_size'] : 0; - $headersText = $headerSize ? substr($response, 0, $headerSize) : ''; - $body = $headerSize ? substr($response, $headerSize) : $response; - - // Parsear cabeceras simples - $parsed = []; - if ($headersText) { - $lines = preg_split('/\r?\n/', $headersText); - foreach ($lines as $line) { - if (strpos($line, ':') !== false) { - list($n, $v) = explode(':', $line, 2); - $parsed[strtolower(trim($n))] = trim($v); - } - } - } - - $contentType = $parsed['content-type'] ?? ($info['content_type'] ?? null); - - // Si la respuesta es JSON, retornar el error en JSON (evitar descargar JSON como archivo) - if ($contentType && stripos($contentType, 'application/json') !== false) { - $decoded = json_decode($body, true); - http_response_code($decoded && isset($decoded['error']) ? 502 : 500); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Remote server returned JSON instead of file', 'remote' => $decoded]); - exit; - } - - // Determinar filename desde Content-Disposition o URL como fallback - $filename = null; - $cd = $parsed['content-disposition'] ?? null; - if ($cd) { - // Usar comillas dobles para el regex que contiene apóstrofes - if (preg_match("/filename\\*=(?:[^']*'')?(.+)$/i", $cd, $m)) { - $filename = trim($m[1], " \"'\r\n"); - $filename = rawurldecode($filename); - } elseif (preg_match('/filename="?([^";]+)"?/i', $cd, $m)) { - $filename = trim($m[1]); - } - } - - if (!$filename) { - $path = parse_url($mediaUrl, PHP_URL_PATH) ?: ''; - $basename = basename($path); - if ($basename && $basename !== '/') { - $filename = $basename; - } - } - - // Si aún no hay filename, intentar deducir extensión desde content-type - if (!$filename) { - $ext = ''; - $mimeMap = [ - 'application/pdf' => '.pdf', - 'text/csv' => '.csv', - 'image/jpeg' => '.jpg', - 'image/png' => '.png', - 'application/zip' => '.zip', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => '.xlsx', - 'application/vnd.ms-excel' => '.xls', - ]; - if ($contentType && isset($mimeMap[strtolower($contentType)])) { - $ext = $mimeMap[strtolower($contentType)]; - } - $filename = 'download' . $ext; - } - - if ($body === null || $body === '') { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Empty response body from remote']); - exit; - } - - // Enviar body como descarga - header_remove('Content-Type'); - header('Cache-Control: public, max-age=3600'); - if (!empty($contentType)) header('Content-Type: ' . $contentType); - header('Content-Length: ' . strlen($body)); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo $body; + media_log("Streaming download for: {$mediaUrl}"); + streamRemoteFile($mediaUrl, $token, true); exit; } -// Por defecto: si la URL fue resuelta mediante Graph API, intentar proxificar -// (hacer la petición desde el servidor añadiendo Authorization si existe el token) -// Esto evita problemas cuando la URL requiere autorización y un 302 directo no funciona. +// Por defecto: si la URL fue resuelta mediante Graph API, streaming proxy +// (evita buffering en memoria para archivos grandes como videos) if (!$wantsJson && !empty($resolvedFromGraph) && $resolvedFromGraph) { - $ch = curl_init(); $token = getConfigFromDB('whatsapp_token', ''); - $headers = ['User-Agent: WhatsAppMediaProxy/1.0', 'Accept: */*']; - if ($token) $headers[] = 'Authorization: Bearer ' . $token; - - curl_setopt_array($ch, [ - CURLOPT_URL => $mediaUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_HTTPHEADER => $headers, - ]); - $body = curl_exec($ch); - $info = curl_getinfo($ch); - $err = curl_error($ch); - curl_close($ch); - - if ($err) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Error al descargar el archivo (proxy)', 'detail' => $err]); - exit; - } - - $remoteHttp = isset($info['http_code']) ? (int)$info['http_code'] : 0; - if ($remoteHttp >= 400) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Remote server returned error status', 'http_code' => $remoteHttp, 'url' => $mediaUrl]); - exit; - } - - $contentType = $info['content_type'] ?? null; - - header_remove('Content-Type'); - header('Cache-Control: public, max-age=3600'); - if (!empty($contentType)) header('Content-Type: ' . $contentType); - header('Content-Length: ' . strlen($body)); - echo $body; + media_log("Streaming Graph-resolved media: {$mediaUrl}"); + streamRemoteFile($mediaUrl, $token); exit; } @@ -714,51 +715,8 @@ if ($token && ($host === 'lookaside.fbsbx.com' || stripos($host, 'graph.facebook } if ($shouldProxy) { - media_log("Proxying Facebook media URL with Authorization to {$mediaUrl}"); - $ch = curl_init(); - $headers = ['User-Agent: WhatsAppMediaProxy/1.0', 'Accept: */*']; - if ($token) $headers[] = 'Authorization: Bearer ' . $token; - - curl_setopt_array($ch, [ - CURLOPT_URL => $mediaUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_HEADER => true, - ]); - $response = curl_exec($ch); - $info = curl_getinfo($ch); - $err = curl_error($ch); - curl_close($ch); - - if ($err) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Error al descargar el archivo (proxy)', 'detail' => $err]); - exit; - } - - $remoteHttp = isset($info['http_code']) ? (int)$info['http_code'] : 0; - if ($remoteHttp >= 400) { - http_response_code(502); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Remote server returned error status', 'http_code' => $remoteHttp, 'url' => $mediaUrl]); - exit; - } - - $headerSize = isset($info['header_size']) ? (int)$info['header_size'] : 0; - $headersText = $headerSize ? substr($response, 0, $headerSize) : ''; - $body = $headerSize ? substr($response, $headerSize) : $response; - - // Parsear content-type desde headers devueltos - $contentType = $info['content_type'] ?? null; - - header_remove('Content-Type'); - header('Cache-Control: public, max-age=3600'); - if (!empty($contentType)) header('Content-Type: ' . $contentType); - header('Content-Length: ' . strlen($body)); - echo $body; + media_log("Streaming Facebook media with Authorization: {$mediaUrl}"); + streamRemoteFile($mediaUrl, $token); exit; } diff --git a/services/WhatsAppService.php b/services/WhatsAppService.php index 8ceba67..48bf240 100644 --- a/services/WhatsAppService.php +++ b/services/WhatsAppService.php @@ -446,7 +446,8 @@ class WhatsAppService CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $this->token ], - CURLOPT_TIMEOUT => 120 + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30 ]); $response = curl_exec($ch);