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 `
+
+ ${caption ? `${escapeHtml(caption)}
` : ''}
+ `;
+ }
+
switch (mediaType) {
case 'image':
return `
${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";