Files
whatsapp/classes/MediaService.php
T

272 lines
11 KiB
PHP

<?php
/**
* Simple MediaService
* - fetchFromGraph(mediaId) -> 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);
}
public function fetchAndStoreFromGraph($mediaId, $subdir = '') {
// Obtener metadata (url) via Graph API
$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,
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 >= 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: primero wget (más confiable con Facebook CDN), luego curl como fallback
$content = $this->downloadWithWget($mediaUrl, !empty($this->token) ? ['Authorization: Bearer ' . $this->token] : []);
if ($content === false) {
// Obtener URL fresca del Graph API (la anterior puede haber expirado tras el intento fallido)
error_log("[MediaService] wget failed, trying curl with fresh URL...");
$ch2 = curl_init($endpoint);
curl_setopt_array($ch2, [
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,
]);
$resp2 = curl_exec($ch2);
curl_close($ch2);
$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 a wget cuando curl falla (OpenSSL incompatibilidad con Facebook CDN)
error_log("[MediaService::downloadUrl] curl failed (http={$http} errno={$errno} err={$err}), trying wget fallback...");
$content = $this->downloadWithWget($url, $headers);
if ($content !== false) {
return $content;
}
error_log("[MediaService::downloadUrl] FAIL url=" . substr($url, 0, 120) . " http={$http} err={$err} wget=also_failed");
return false;
}
/**
* Fallback: descargar con wget (usa GnuTLS en vez de OpenSSL, más compatible con Facebook CDN)
*/
private function downloadWithWget($url, $headers = []) {
$tmpFile = tempnam(sys_get_temp_dir(), 'wget_');
$headerArgs = '';
foreach ($headers as $h) {
// Solo pasar headers que wget soporte
$headerArgs .= ' --header=' . escapeshellarg($h);
}
// Intentar con GNU wget primero (soporta --prefer-family=IPv4)
$cmd = 'wget -q --prefer-family=IPv4 -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url) . ' 2>&1';
exec($cmd, $output, $exitCode);
if ($exitCode === 0 && file_exists($tmpFile) && filesize($tmpFile) > 0) {
$content = file_get_contents($tmpFile);
unlink($tmpFile);
error_log("[MediaService::downloadWithWget] OK, " . strlen($content) . " bytes");
return $content;
}
// Fallback sin --prefer-family (BusyBox wget no lo soporta)
$cmd2 = 'wget -q -O ' . escapeshellarg($tmpFile) . $headerArgs . ' ' . escapeshellarg($url) . ' 2>&1';
exec($cmd2, $output2, $exitCode2);
if ($exitCode2 === 0 && file_exists($tmpFile) && filesize($tmpFile) > 0) {
$content = file_get_contents($tmpFile);
unlink($tmpFile);
error_log("[MediaService::downloadWithWget] OK (basic), " . strlen($content) . " bytes");
return $content;
}
error_log("[MediaService::downloadWithWget] FAIL exit={$exitCode}/{$exitCode2} output=" . implode(' ', array_merge($output, $output2)));
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;
}
}