From 602e6d372497ab882e6126bd20dc4bbb535777fb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:21:06 -0500 Subject: [PATCH] up --- api/get_conversation_detail.php | 4 +- api/version/media-url.php | 1 + api/webhook.php | 1 + assets/js/chat-common.js | 11 ++- classes/MediaService.php | 4 +- conversations.php | 27 +++++- docker-compose.prod.yml | 4 +- docker/entrypoint.sh | 3 + docker/nginx/ssl-prod.conf | 2 +- migrations/20260220_media_queue_attempts.sql | 4 + scripts/media_worker.php | 99 ++++++++++++++++---- 11 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 migrations/20260220_media_queue_attempts.sql diff --git a/api/get_conversation_detail.php b/api/get_conversation_detail.php index f35cf58..e99409b 100644 --- a/api/get_conversation_detail.php +++ b/api/get_conversation_detail.php @@ -77,7 +77,7 @@ try { // Formatear mensajes $conversations = array_map(function($msg) { // Build external URL if available (media id -> api/get_media proxy) - $external = (isset($msg['media_url']) && $msg['media_url']) ? (preg_match('#^https?://#i', $msg['media_url']) ? $msg['media_url'] : ("api/get_media.php?id=" . urlencode($msg['media_url']))) : null; + $external = (isset($msg['media_url']) && $msg['media_url']) ? (preg_match('#^https?://#i', $msg['media_url']) ? $msg['media_url'] : ("api/version/media-url.php?id=" . urlencode($msg['media_url']))) : null; // Fallback: if no external URL but content is JSON with link, extract it $contentStr = $msg['content'] ?? ''; @@ -99,7 +99,7 @@ try { foreach ($candidates as $cand) { if ($cand) { // if looks like id or url - $external = preg_match('#^https?://#i', $cand) ? $cand : (strpos($cand, 'http') === false ? "api/get_media.php?id=" . urlencode($cand) : $cand); + $external = preg_match('#^https?://#i', $cand) ? $cand : (strpos($cand, 'http') === false ? "api/version/media-url.php?id=" . urlencode($cand) : $cand); break; } } diff --git a/api/version/media-url.php b/api/version/media-url.php index d661bc9..c340ea3 100644 --- a/api/version/media-url.php +++ b/api/version/media-url.php @@ -140,6 +140,7 @@ if ($mediaId) { // ── Si no hay archivo local, descargar con MediaService y guardar como cache permanente ── if ($mediaId) { try { + set_time_limit(180); // Dar tiempo suficiente para fotos grandes (hasta 5MB) require_once __DIR__ . '/../../classes/MediaService.php'; $ms = new MediaService(); $subdir = date('Y/m'); diff --git a/api/webhook.php b/api/webhook.php index e4e4890..e8a1e2e 100644 --- a/api/webhook.php +++ b/api/webhook.php @@ -226,6 +226,7 @@ class WhatsAppWebhook { $localThumb = null; if (!empty($mediaUrl) && in_array($messageType, ['image', 'audio', 'video', 'document', 'sticker'])) { try { + set_time_limit(180); // Tiempo extra para descargar media pesado $mediaService = new MediaService(); $subdir = date('Y/m'); // Organizar por año/mes diff --git a/assets/js/chat-common.js b/assets/js/chat-common.js index 5310d3a..8507106 100644 --- a/assets/js/chat-common.js +++ b/assets/js/chat-common.js @@ -77,7 +77,14 @@ function renderMediaMessage(msg) { thumbUrl = `/api/version/media-url.php?url=${encodeURIComponent(external)}`; } } else { - thumbUrl = external; + // Route through media-url.php proxy (handles caching + auth) + if (external.indexOf('api/get_media.php?id=') !== -1 || external.indexOf('api/version/media-url.php?id=') !== -1) { + const parts = external.split('id='); + const mid = parts[1] ? decodeURIComponent(parts[1]) : ''; + thumbUrl = mid ? `/api/version/media-url.php?id=${encodeURIComponent(mid)}` : external; + } else { + thumbUrl = ensureProxyUrl(external) || external; + } } } else if (isValidMediaUrl(msg.media_url)) { thumbUrl = msg.media_url; @@ -90,7 +97,7 @@ function renderMediaMessage(msg) { fullUrl = localFile.startsWith('/') ? localFile : `/${localFile}`; } else if (isValidMediaUrl(mediaExternal)) { let mid = ''; - if (mediaExternal.indexOf('api/get_media.php?id=') !== -1) { + if (mediaExternal.indexOf('api/get_media.php?id=') !== -1 || mediaExternal.indexOf('api/version/media-url.php?id=') !== -1) { const parts = mediaExternal.split('id='); mid = parts[1] ? decodeURIComponent(parts[1]) : ''; } else { const m = mediaExternal.match(/[?&]mid=([^&]+)/i); if (m && m[1]) mid = decodeURIComponent(m[1]); diff --git a/classes/MediaService.php b/classes/MediaService.php index 627f8f4..2d51f38 100644 --- a/classes/MediaService.php +++ b/classes/MediaService.php @@ -116,8 +116,10 @@ class MediaService { CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 120, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_TIMEOUT => 180, CURLOPT_HTTPHEADER => $headers, + CURLOPT_BUFFERSIZE => 65536, ]); $content = curl_exec($ch); $err = curl_error($ch); diff --git a/conversations.php b/conversations.php index 970d031..effc8b2 100644 --- a/conversations.php +++ b/conversations.php @@ -5704,9 +5704,9 @@ if (!isset($_SESSION['user_id'])) { console.log('🔄 Usando media_url_external (URL absoluta):', full); } else if (mediaUrl && /^\d+$/.test(mediaUrl)) { // 📱 PRIORIDAD 4: Es un ID de WhatsApp (solo números) - usar URL absoluta - full = `${baseUrl}/api/get_media.php?id=${encodeURIComponent(mediaUrl)}`; + full = `${baseUrl}/api/version/media-url.php?id=${encodeURIComponent(mediaUrl)}`; thumb = full; - console.log('📱 Usando whatsapp_media_id con get_media.php:', mediaUrl); + console.log('📱 Usando whatsapp_media_id con media-url.php:', mediaUrl); } else if (mediaUrl && /^https?:\/\//i.test(mediaUrl)) { // 🌐 PRIORIDAD 5: URL externa directa (puede estar caducada) full = mediaUrl; @@ -5719,12 +5719,33 @@ if (!isset($_SESSION['user_id'])) { thumb = ''; } + // Si no hay URL de media, mostrar placeholder con botón de descarga + if (!full && mediaType !== 'text') { + const retryUrl = mediaUrl && /^\d+$/.test(mediaUrl) + ? `${baseUrl}/api/version/media-url.php?id=${encodeURIComponent(mediaUrl)}` + : (mediaUrlExternal ? `${baseUrl}/${mediaUrlExternal.replace(/^\//, '')}` : ''); + const retryBtn = retryUrl + ? ` Descargar ahora` + : ''; + const iconMap = {image: 'fa-image', video: 'fa-video', audio: 'fa-microphone', document: 'fa-file', sticker: 'fa-sticky-note'}; + const icon = iconMap[mediaType] || 'fa-file'; + return ` +
+ +
Descargando ${escapeHtml(mediaType)}...
+
En cola de descarga
+ ${retryBtn} +
+ ${caption ? `
${escapeHtml(caption)}
` : ''} + `; + } + switch (mediaType) { case 'image': return `
- Imagen + Imagen
${caption ? `
${escapeHtml(caption)}
` : ''} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5e34fc1..3e9bd02 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -78,8 +78,8 @@ services: - ERROR_REPORTING=0 # PHP optimizado para producción - - PHP_MEMORY_LIMIT=256M - - PHP_MAX_EXECUTION_TIME=60 + - PHP_MEMORY_LIMIT=512M + - PHP_MAX_EXECUTION_TIME=300 - PHP_OPCACHE_ENABLE=1 depends_on: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b2e952f..b4b4dff 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -121,6 +121,9 @@ cat > /etc/crontabs/www <processDelayed('messages'); \$q->processDelayed('media');" 2>&1 | logger -t delayed-processor +# Procesar cola de media pendiente cada 2 minutos +*/2 * * * * cd /var/www/html && /usr/local/bin/php scripts/media_worker.php 2>&1 | logger -t media-worker + # Limpiar logs antiguos (diario a las 3 AM) 0 3 * * * cd /var/www/html && /usr/local/bin/php -r "require 'classes/LoggerFactory.php'; echo LoggerFactory::cleanup(30) . ' logs eliminados';" 2>&1 | logger -t log-cleanup diff --git a/docker/nginx/ssl-prod.conf b/docker/nginx/ssl-prod.conf index 2188ee0..48f96c2 100644 --- a/docker/nginx/ssl-prod.conf +++ b/docker/nginx/ssl-prod.conf @@ -46,7 +46,7 @@ server { index index.php index.html; # Tamaño máximo de upload - client_max_body_size 50M; + client_max_body_size 100M; # Logs access_log /var/log/nginx/ssl_access.log; diff --git a/migrations/20260220_media_queue_attempts.sql b/migrations/20260220_media_queue_attempts.sql new file mode 100644 index 0000000..a7b6f07 --- /dev/null +++ b/migrations/20260220_media_queue_attempts.sql @@ -0,0 +1,4 @@ +-- Add attempts column and expand status enum for media_queue retry logic +ALTER TABLE media_queue + ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0, + MODIFY COLUMN status ENUM('pending','processing','done','failed','permanently_failed') DEFAULT 'pending'; diff --git a/scripts/media_worker.php b/scripts/media_worker.php index 12e244f..b2bf427 100644 --- a/scripts/media_worker.php +++ b/scripts/media_worker.php @@ -1,37 +1,98 @@ fetchAll('SELECT * FROM media_queue WHERE status = ? ORDER BY created_at ASC LIMIT 10', ['pending']); + +// Incluir 'failed' con menos de 5 intentos para reintento +$rows = $db->fetchAll( + "SELECT * FROM media_queue WHERE (status = 'pending' OR (status = 'failed' AND (attempts IS NULL OR attempts < 5))) ORDER BY created_at ASC LIMIT 10" +); + +if (empty($rows)) { + echo date('Y-m-d H:i:s') . " - No pending media jobs\n"; + exit(0); +} + $ms = new MediaService(); +$processed = 0; +$success = 0; +$failed = 0; + foreach ($rows as $r) { + $processed++; + $attempts = intval($r['attempts'] ?? 0) + 1; + try { + // Marcar como procesando + $db->query("UPDATE media_queue SET status = 'processing', attempts = :att WHERE id = :id", ['att' => $attempts, 'id' => $r['id']]); + $res = null; if (!empty($r['media_id'])) { $res = $ms->fetchAndStoreFromGraph($r['media_id'], $r['subdir'] ?? date('Y/m')); } elseif (!empty($r['media_url'])) { - $res = $ms->fetchAndStoreFromUrl($r['media_url'], $r['subdir'] ?? date('Y/m')); - } - - if ($res) { - // Actualizar fila media_queue - $db->update('media_queue', ['status' => 'done', 'result' => json_encode($res)], 'id = :id', ['id' => $r['id']]); - // Actualizar conversations si tenemos conversation_id - if (!empty($r['conversation_id'])) { - $update = []; - if (!empty($res['local_file'])) $update['local_file'] = $res['local_file']; - if (!empty($res['local_thumb'])) $update['local_thumb'] = $res['local_thumb']; - if (!empty($update)) { - $db->update('conversations', $update, 'id = :id', ['id' => $r['conversation_id']]); - } + if (preg_match('/^\d+$/', $r['media_url'])) { + $res = $ms->fetchAndStoreFromGraph($r['media_url'], $r['subdir'] ?? date('Y/m')); + } else { + $res = $ms->fetchAndStoreFromUrl($r['media_url'], $r['subdir'] ?? date('Y/m')); } } + + if ($res && !empty($res['local_file'])) { + // Éxito + $db->query( + "UPDATE media_queue SET status = 'done', result = :res WHERE id = :id", + ['res' => json_encode($res), 'id' => $r['id']] + ); + + // Actualizar conversations con el archivo local + if (!empty($r['conversation_id'])) { + $update = ['local_file' => $res['local_file']]; + if (!empty($res['local_thumb'])) $update['local_thumb'] = $res['local_thumb']; + if (!empty($res['mime_type'])) $update['mime_type'] = $res['mime_type']; + $db->update('conversations', $update, 'id = :id', ['id' => $r['conversation_id']]); + echo date('Y-m-d H:i:s') . " - OK Media #{$r['id']} -> {$res['local_file']} (conv:{$r['conversation_id']})\n"; + } else { + // Sin conversation_id: buscar por media_id/media_url + $mediaRef = !empty($r['media_id']) ? $r['media_id'] : $r['media_url']; + if ($mediaRef) { + try { + $db->query( + "UPDATE conversations SET local_file = :lf, local_thumb = :lt, mime_type = :mt WHERE (media_url = :mid OR whatsapp_media_id = :mid2) AND (local_file IS NULL OR local_file = '') LIMIT 5", + [ + 'lf' => $res['local_file'], + 'lt' => $res['local_thumb'] ?? null, + 'mt' => $res['mime_type'] ?? null, + 'mid' => $mediaRef, + 'mid2' => $mediaRef + ] + ); + } catch (Exception $e) { + error_log("[media_worker] Error updating conversations: " . $e->getMessage()); + } + } + echo date('Y-m-d H:i:s') . " - OK Media #{$r['id']} -> {$res['local_file']} (sin conv_id)\n"; + } + $success++; + } else { + throw new Exception('Download returned empty result'); + } } catch (Exception $e) { - $db->update('media_queue', ['status' => 'failed', 'result' => json_encode(['error' => $e->getMessage()])], 'id = :id', ['id' => $r['id']]); + $failed++; + $status = ($attempts >= 5) ? 'permanently_failed' : 'failed'; + $db->query( + "UPDATE media_queue SET status = :st, attempts = :att, result = :res WHERE id = :id", + ['st' => $status, 'att' => $attempts, 'res' => json_encode(['error' => $e->getMessage()]), 'id' => $r['id']] + ); + echo date('Y-m-d H:i:s') . " - FAIL Media #{$r['id']} attempt {$attempts}: " . $e->getMessage() . "\n"; } } -echo "Processed " . count($rows) . " jobs\n"; + +echo date('Y-m-d H:i:s') . " - Processed {$processed}: {$success} ok, {$failed} failed\n";