diff --git a/api/get_conversation_detail.php b/api/get_conversation_detail.php index 4f48e46..77420af 100644 --- a/api/get_conversation_detail.php +++ b/api/get_conversation_detail.php @@ -69,7 +69,10 @@ try { 'direction' => $msg['direction'], 'message_type' => $msg['message_type'], 'content' => $msg['content'] ?? '', - 'media_url' => (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, + 'media_url' => null, + 'local_file' => $msg['local_file'] ?? null, + 'local_thumb' => $msg['local_thumb'] ?? null, + 'media_url_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, 'filename' => $msg['filename'] ?? null, 'mime_type' => $msg['mime_type'] ?? null, 'status' => $msg['status'] ?? 'sent', diff --git a/api/get_user_messages.php b/api/get_user_messages.php index d89d1c2..013adda 100644 --- a/api/get_user_messages.php +++ b/api/get_user_messages.php @@ -86,7 +86,10 @@ try { 'user_id' => intval($msg['user_id']), 'content' => $msg['content'] ?? '', 'message_id' => $msg['message_id'] ?? null, - 'media_url' => (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, + 'media_url' => null, + 'local_file' => $msg['local_file'] ?? null, + 'local_thumb' => $msg['local_thumb'] ?? null, + 'media_url_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, 'filename' => $msg['filename'] ?? null, 'mime_type' => $msg['mime_type'] ?? null, 'user_phone' => $msg['user_phone'] ?? null, diff --git a/api/version/media-url.php b/api/version/media-url.php index 69a9326..82e07cf 100644 --- a/api/version/media-url.php +++ b/api/version/media-url.php @@ -70,6 +70,49 @@ if ($providedUrl) { } } +// Soporte para archivos locales (local=uploads/media/...) +$local = $_GET['local'] ?? null; +if ($local) { + $localPath = realpath(__DIR__ . '/../../' . ltrim($local, '/')); + $base = realpath(__DIR__ . '/../../uploads/media'); + // Prevención simple de path traversal + if (!$localPath || strpos($localPath, $base) !== 0) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Ruta local inválida']); + exit; + } + + if (!file_exists($localPath)) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Archivo no encontrado']); + exit; + } + + if ($wantsJson) { + // devolver URL pública directa (relative) + echo json_encode(['success' => true, 'url' => '/' . ltrim($local, '/')]); + exit; + } + + if ($download) { + // Forzar descarga del archivo local + header('Cache-Control: public, max-age=3600'); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $ctype = finfo_file($finfo, $localPath); + finfo_close($finfo); + header('Content-Type: ' . $ctype); + header('Content-Length: ' . filesize($localPath)); + header('Content-Disposition: attachment; filename="' . basename($localPath) . '"'); + readfile($localPath); + exit; + } + + // Redirigir al archivo local (servidor web lo servirá) + header('Cache-Control: public, max-age=3600'); + header('Location: /' . ltrim($local, '/'), true, 302); + exit; +} + // Si se solicitó JSON, devolver la URL if ($wantsJson) { echo json_encode(['success' => true, 'url' => $mediaUrl]); @@ -79,11 +122,17 @@ if ($wantsJson) { // Si se pidió proxy (download=1), traer el contenido y retornarlo como descarga if ($download) { $ch = curl_init(); + // Añadir Authorization si existe token en configuración + $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, ]); $content = curl_exec($ch); $info = curl_getinfo($ch); diff --git a/api/webhook.php b/api/webhook.php index 46c1162..09184f8 100644 --- a/api/webhook.php +++ b/api/webhook.php @@ -164,23 +164,103 @@ class WhatsAppWebhook { $mediaUrl = $message['image']['url'] ?? ($message['image']['id'] ?? null); $mimeType = $message['image']['mime_type'] ?? null; $filename = $message['image']['filename'] ?? null; + + // Intentar descargar y almacenar imagen localmente + try { + $ms = new MediaService(); + $res = null; + if ($mediaUrl && preg_match('#^https?://#i', $mediaUrl)) { + // Si venía URL directa, descargar desde URL + $content = $ms->downloadUrl($mediaUrl); + if ($content !== false) { + $subdir = date('Y/m'); + $dir = 'uploads/media/' . $subdir; + if (!is_dir(__DIR__ . '/../' . $dir)) mkdir(__DIR__ . '/../' . $dir, 0755, true); + $ext = $ms->extensionFromMime($mimeType) ?: pathinfo(parse_url($mediaUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'jpg'; + $fname = uniqid('m_') . '.' . $ext; + $path = __DIR__ . '/../' . $dir . '/' . $fname; + file_put_contents($path, $content); + $res = ['local_file' => $dir . '/' . $fname]; + // crear thumb + if ($ms->isImageMime($mimeType)) { + $thumbPath = __DIR__ . '/../' . $dir . '/thumb_' . $fname . '.jpg'; + if ($ms->createThumbnail($path, $thumbPath)) { + $res['local_thumb'] = $dir . '/thumb_' . $fname . '.jpg'; + } + } + } + } elseif ($mediaUrl) { + // mediaUrl probablemente es media id -> usar fetchAndStoreFromGraph + $res = $ms->fetchAndStoreFromGraph($mediaUrl, date('Y/m')); + } + if ($res) { + $localFile = $res['local_file'] ?? null; + $localThumb = $res['local_thumb'] ?? null; + } + } catch (Exception $e) { + error_log('Media download failed: ' . $e->getMessage()); + // Encolar para reintento si tenemos un media id (no descargar documentos aquí) + try { + if ($mediaUrl && !preg_match('#^https?://#i', $mediaUrl)) { + $this->db->insert('media_queue', [ + 'media_id' => $mediaUrl, + 'subdir' => date('Y/m'), + 'status' => 'pending', + 'created_at' => date('Y-m-d H:i:s') + ]); + } + } catch (Exception $e2) { + error_log('Failed to enqueue media download: ' . $e2->getMessage()); + } + } + } elseif (isset($message['audio'])) { $messageType = 'audio'; $mediaUrl = $message['audio']['url'] ?? ($message['audio']['id'] ?? null); $mimeType = $message['audio']['mime_type'] ?? null; $filename = $message['audio']['filename'] ?? null; + + // Descargar audio y guardar local (opcional) + try { + $ms = new MediaService(); + if ($mediaUrl && !preg_match('#^https?://#i', $mediaUrl)) { + $res = $ms->fetchAndStoreFromGraph($mediaUrl, date('Y/m')); + if ($res) { + $localFile = $res['local_file'] ?? null; + } + } + } catch (Exception $e) { + error_log('Audio download failed: ' . $e->getMessage()); + } + } elseif (isset($message['video'])) { $messageText = $message['video']['caption'] ?? ''; $messageType = 'video'; $mediaUrl = $message['video']['url'] ?? ($message['video']['id'] ?? null); $mimeType = $message['video']['mime_type'] ?? null; $filename = $message['video']['filename'] ?? null; + + // Descargar video y guardar local (opcional) + try { + $ms = new MediaService(); + if ($mediaUrl && !preg_match('#^https?://#i', $mediaUrl)) { + $res = $ms->fetchAndStoreFromGraph($mediaUrl, date('Y/m')); + if ($res) { + $localFile = $res['local_file'] ?? null; + } + } + } catch (Exception $e) { + error_log('Video download failed: ' . $e->getMessage()); + } + } elseif (isset($message['document'])) { $messageText = $message['document']['filename'] ?? ''; $messageType = 'document'; $mediaUrl = $message['document']['url'] ?? ($message['document']['id'] ?? null); $mimeType = $message['document']['mime_type'] ?? null; $filename = $message['document']['filename'] ?? null; + + // Do NOT download documents automatically; keep media id so browser will redirect and download } // Log debug (temporal) para verificar cómo llegan media_url/filename @@ -205,6 +285,13 @@ class WhatsAppWebhook { if (isset($mimeType) && $mimeType !== null) { $saveData['mime_type'] = $mimeType; } + // Si se descargó y almacenó localmente, añadir las rutas locales + if (isset($localFile) && $localFile) { + $saveData['local_file'] = $localFile; + } + if (isset($localThumb) && $localThumb) { + $saveData['local_thumb'] = $localThumb; + } // Si el mensaje incluye contexto (es respuesta a otro mensaje) if (isset($message['context']) && isset($message['context']['id'])) { diff --git a/classes/MediaService.php b/classes/MediaService.php new file mode 100644 index 0000000..2be4d42 --- /dev/null +++ b/classes/MediaService.php @@ -0,0 +1,143 @@ + gets Graph API URL and downloads + * - storeContent($bytes, $destPath) + * - createThumbnail($srcPath, $thumbPath) + */ +class MediaService { + private $token; + private $apiUrl; + private $uploadsDir; + + public function __construct() { + $this->token = getConfigFromDB('whatsapp_token', ''); + $this->apiUrl = rtrim(getConfigFromDB('whatsapp_api_url', 'https://graph.facebook.com/v22.0/'), '/'); + $this->uploadsDir = __DIR__ . '/../uploads/media'; + if (!is_dir($this->uploadsDir)) mkdir($this->uploadsDir, 0755, true); + } + + public function fetchAndStoreFromGraph($mediaId, $subdir = '') { + // Obtener metadata (url) + $endpoint = "{$this->apiUrl}/{$mediaId}"; + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token], + CURLOPT_TIMEOUT => 15 + ]); + $resp = curl_exec($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err || $http >= 400) { + throw new Exception('Graph API fetch failed: ' . $err); + } + $decoded = json_decode($resp, true); + $mediaUrl = $decoded['url'] ?? ($decoded['data'][0]['url'] ?? null); + if (!$mediaUrl) throw new Exception('No media url from Graph'); + + // Descargar contenido (primer intento) + $content = $this->downloadUrl($mediaUrl); + if ($content === false) { + // Reintentar con header Referer (algunos endpoints requieren referer) + $content = $this->downloadUrl($mediaUrl, ['Referer: https://graph.facebook.com']); + } + 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; + } + + 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_TIMEOUT => 30, + CURLOPT_HTTPHEADER => $headers, + ]); + $content = curl_exec($ch); + $err = curl_error($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($err || $http >= 400) return false; + return $content; + } + + public function extensionFromMime($mime) { + if (!$mime) return null; + $map = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'audio/mpeg' => 'mp3', + 'audio/ogg' => 'ogg', + 'video/mp4' => 'mp4', + 'text/csv' => 'csv', + 'application/pdf' => 'pdf' + ]; + return $map[$mime] ?? 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; + } +} diff --git a/conversations.php b/conversations.php index 3ba1b16..0ca3368 100644 --- a/conversations.php +++ b/conversations.php @@ -967,7 +967,8 @@ const statusIcon = this.getStatusIcon(msg.status); // Renderizar contenido (texto o multimedia) - const content = msg.media_url + // Preferir miniatura local para previsualización si existe + const content = (msg.local_thumb || msg.local_file || msg.media_url_external) ? this.renderMediaMessage(msg) : (msg.content || msg.message_text || '[Mensaje vacío]'); @@ -1354,18 +1355,22 @@ switch (mediaType) { case 'image': + // Usar miniatura local si existe, al abrir expandir con el archivo local completo o con media-url redirect + const thumb = msg.local_thumb ? (`/${msg.local_thumb}`) : (msg.local_file ? (`/${msg.local_file}`) : (msg.media_url_external || mediaUrl)); + const full = msg.local_file ? (`/api/version/media-url.php?local=${encodeURIComponent(msg.local_file)}`) : (`/api/version/media-url.php?id=${encodeURIComponent(msg.media_url || '')}`); return `
${caption ? `