This commit is contained in:
Lizandro Guarnizo
2026-02-21 11:24:15 -05:00
parent 26afa7f36b
commit 5dd48ba5c2
5 changed files with 378 additions and 59 deletions
+127
View File
@@ -0,0 +1,127 @@
<?php
/**
* API - Forzar descarga inmediata de un archivo multimedia
* Agrega/reinicia el item en la cola y ejecuta el worker sincrónicamente para ese item.
*/
require_once '../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$messageId = isset($input['message_id']) ? intval($input['message_id']) : 0;
if (!$messageId) {
echo json_encode(['success' => false, 'error' => 'message_id requerido']);
exit;
}
$db = Database::getInstance();
// Obtener info del mensaje
$msg = $db->fetchOne(
"SELECT id, media_url, whatsapp_media_id, message_type, local_file, conversation_id
FROM conversations
WHERE id = ?",
[$messageId]
);
if (!$msg) {
echo json_encode(['success' => false, 'error' => 'Mensaje no encontrado']);
exit;
}
// Si ya tiene archivo local, retornar éxito directo
if (!empty($msg['local_file']) && file_exists($_SERVER['DOCUMENT_ROOT'] . '/' . $msg['local_file'])) {
echo json_encode(['success' => true, 'status' => 'already_downloaded', 'local_file' => $msg['local_file']]);
exit;
}
// Determinar el media_id a descargar
$mediaId = !empty($msg['whatsapp_media_id']) ? $msg['whatsapp_media_id'] : $msg['media_url'];
if (empty($mediaId)) {
echo json_encode(['success' => false, 'error' => 'Sin media_id disponible']);
exit;
}
// Buscar o crear entrada en media_queue
$existing = $db->fetchOne(
"SELECT id, status, attempts FROM media_queue WHERE conversation_id = ? OR media_id = ? LIMIT 1",
[$msg['id'], $mediaId]
);
if ($existing) {
// Resetear a pending para que sea procesado
$db->query(
"UPDATE media_queue SET status = 'pending', attempts = 0, result = NULL, updated_at = NOW() WHERE id = ?",
[$existing['id']]
);
$queueId = $existing['id'];
} else {
// Crear nueva entrada en la cola
$convInfo = $db->fetchOne("SELECT user_id FROM conversations WHERE id = ?", [$msg['id']]);
$userId = $convInfo['user_id'] ?? 0;
// Determinar subdirectorio por mes
$subdir = date('Y/m');
$db->query(
"INSERT INTO media_queue (media_id, subdir, status, attempts, conversation_id, media_url, message_type, created_at)
VALUES (?, ?, 'pending', 0, ?, ?, ?, NOW())",
[$mediaId, $subdir, $msg['id'], $mediaId, $msg['message_type'] ?? 'image']
);
$queueId = $db->lastInsertId();
}
// Ejecutar descarga inmediata usando MediaService
$result = ['success' => false, 'status' => 'queued'];
try {
require_once '../classes/MediaService.php';
$mediaService = new MediaService();
$subdir = date('Y/m');
$localPath = $mediaService->fetchAndStoreFromGraph($mediaId, $subdir);
if ($localPath) {
// Actualizar el mensaje con el archivo local
$db->query(
"UPDATE conversations SET local_file = ?, updated_at = NOW() WHERE id = ?",
[$localPath, $messageId]
);
// Marcar como completado en la cola
$db->query(
"UPDATE media_queue SET status = 'completed', result = ?, updated_at = NOW() WHERE id = ?",
['OK: ' . $localPath, $queueId]
);
$result = ['success' => true, 'status' => 'downloaded', 'local_file' => $localPath];
} else {
// Falló la descarga pero quedó en cola para el worker
$db->query(
"UPDATE media_queue SET status = 'failed', attempts = attempts + 1, updated_at = NOW() WHERE id = ?",
[$queueId]
);
$result = ['success' => false, 'status' => 'failed', 'message' => 'No se pudo descargar ahora. Quedó en cola para reintento automático.'];
}
} catch (Exception $e) {
error_log('[force_download_media] Error al descargar: ' . $e->getMessage());
$result = ['success' => false, 'status' => 'failed', 'message' => 'Error: ' . $e->getMessage()];
}
echo json_encode($result);
} catch (Exception $e) {
error_log('[force_download_media] Exception: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+30 -2
View File
@@ -159,8 +159,35 @@ try {
$conversations = [];
}
// Si hay búsqueda, obtener el snippet del mensaje que coincidió (no solo el último)
$matchedContentMap = [];
if (!empty($search)) {
$userIds = array_column($conversations, 'user_id');
if (!empty($userIds)) {
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$searchParam = '%' . $search . '%';
$params2 = array_merge([$searchParam], $userIds);
$matchedRows = $db->fetchAll(
"SELECT user_id, content FROM conversations
WHERE content LIKE ? AND user_id IN ($placeholders)
ORDER BY created_at DESC",
$params2
);
// Guardar el primer mensaje coincidente por usuario
foreach ($matchedRows as $row) {
if (!isset($matchedContentMap[$row['user_id']])) {
$matchedContentMap[$row['user_id']] = mb_substr($row['content'], 0, 80);
}
}
}
}
// Formatear datos para el frontend
$conversations = array_map(function($conv) {
$conversations = array_map(function($conv) use ($matchedContentMap, $search) {
$matched = null;
if (!empty($search) && isset($matchedContentMap[$conv['user_id']])) {
$matched = $matchedContentMap[$conv['user_id']];
}
return [
'user_id' => intval($conv['user_id']),
'name' => $conv['name'] ?? $conv['phone_number'],
@@ -178,7 +205,8 @@ try {
'advisor_requested' => isset($conv['advisor_requested']) ? (bool)$conv['advisor_requested'] : false,
'in_service' => isset($conv['in_service']) ? (bool)$conv['in_service'] : false,
'in_service_by' => isset($conv['in_service_by']) ? intval($conv['in_service_by']) : null,
'in_service_at' => $conv['in_service_at'] ?? null
'in_service_at' => $conv['in_service_at'] ?? null,
'matched_content' => $matched // snippet del mensaje que coincidió (solo en búsqueda)
];
}, $conversations);
+9
View File
@@ -326,3 +326,12 @@
[2026-02-21 08:41:55] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSODUxOUM2MzM5RUU4NDE0RjA4AA=="}]}
[2026-02-21 08:41:55] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSODUxOUM2MzM5RUU4NDE0RjA4AA=="}]}
[2026-02-21 08:41:55] Saving to DB - content: 3 (2).png, media_url: 2095167437923441, local_file: uploads/media_6999b620bce0e2.99054574.png, local_thumb: uploads/media_6999b620bce0e2.99054574.png
[2026-02-21 11:19:23] Raw input: {"recipient":"573022548060","media_url":"https://bot.u-s.app/uploads/media_6999db0b6b3f01.27857143.png","media_type":"image","caption":null,"filename":"image.png","is_voice":false}
[2026-02-21 11:19:23] Input decoded: {"recipient":"573022548060","media_url":"https:\/\/bot.u-s.app\/uploads\/media_6999db0b6b3f01.27857143.png","media_type":"image","caption":null,"filename":"image.png","is_voice":false}
[2026-02-21 11:19:23] is_voice: false
[2026-02-21 11:19:23] Local file: /var/www/html/api/../uploads/media_6999db0b6b3f01.27857143.png exists=yes size=2515546
[2026-02-21 11:19:25] Upload result: {"id":"25968821909466059"}
[2026-02-21 11:19:25] Media ID obtained: 25968821909466059
[2026-02-21 11:19:26] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSNEI4QkZFQjk2QjEyNkQ4RkMwAA=="}]}
[2026-02-21 11:19:26] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573022548060","wa_id":"573022548060"}],"messages":[{"id":"wamid.HBgMNTczMDIyNTQ4MDYwFQIAERgSNEI4QkZFQjk2QjEyNkQ4RkMwAA=="}]}
[2026-02-21 11:19:26] Saving to DB - content: image.png, media_url: 25968821909466059, local_file: uploads/media_6999db0b6b3f01.27857143.png, local_thumb: uploads/media_6999db0b6b3f01.27857143.png
+128 -37
View File
@@ -88,8 +88,21 @@ if (!isset($_SESSION['user_id'])) {
flex: 1;
overflow-y: auto;
padding: 6px;
scroll-behavior: smooth;
}
/* Scrollbar personalizado estilo WhatsApp (fino y discreto) */
.conversation-list::-webkit-scrollbar { width: 4px; }
.conversation-list::-webkit-scrollbar-track { background: transparent; }
.conversation-list::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; }
.conversation-list::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.28); }
/* Buscador mejorado */
.search-wrapper { position: relative; }
.search-spinner { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none; color: #999; font-size: 13px; }
.search-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none; background: none; border: none; padding: 0; color: #999; cursor: pointer; font-size: 14px; line-height: 1; }
.search-clear:hover { color: #333; }
.conversation-item {
padding: 12px 14px;
border-bottom: 1px solid #f1f5f8;
@@ -847,11 +860,13 @@ if (!isset($_SESSION['user_id'])) {
</div>
<div class="sidebar-search">
<div class="input-group">
<div class="input-group search-wrapper" style="position:relative;">
<span class="input-group-text bg-transparent border-0">
<i class="fas fa-search text-muted"></i>
<i class="fas fa-search text-muted" id="search-icon"></i>
</span>
<input type="text" class="form-control border-0" placeholder="Buscar conversaciones..." id="search-input">
<input type="text" class="form-control border-0" placeholder="Buscar por nombre, teléfono o mensaje..." id="search-input" autocomplete="off" style="padding-right:28px;">
<span class="search-spinner" id="search-spinner"><i class="fas fa-circle-notch fa-spin"></i></span>
<button class="search-clear" id="search-clear" title="Limpiar búsqueda">&#x2715;</button>
</div>
</div>
@@ -1797,13 +1812,34 @@ if (!isset($_SESSION['user_id'])) {
setupEventListeners() {
// Búsqueda de conversaciones con debounce
let searchTimeout;
document.getElementById('search-input').addEventListener('input', (e) => {
const searchInput = document.getElementById('search-input');
const searchClear = document.getElementById('search-clear');
const searchSpinner = document.getElementById('search-spinner');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.searchConversations(e.target.value);
}, 500); // Esperar 500ms después de que el usuario deje de escribir
const val = e.target.value;
// Mostrar/ocultar botón X
if (searchClear) searchClear.style.display = val ? 'block' : 'none';
// Mostrar spinner mientras espera debounce
if (searchSpinner && val) searchSpinner.style.display = 'block';
searchTimeout = setTimeout(async () => {
if (searchSpinner) searchSpinner.style.display = 'none';
await this.searchConversations(val);
}, 300);
});
if (searchClear) {
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchClear.style.display = 'none';
if (searchSpinner) searchSpinner.style.display = 'none';
clearTimeout(searchTimeout);
this.searchConversations('');
searchInput.focus();
});
}
// Envío de mensajes
document.getElementById('send-btn').addEventListener('click', () => {
this.sendMessage();
@@ -2555,7 +2591,7 @@ if (!isset($_SESSION['user_id'])) {
console.error('❌ No se encontró el elemento #conversation-list en el DOM');
return;
}
console.log('✅ Container encontrado:', container);
console.log('🔍 Filtro activo:', this.conversationFilter);
@@ -2604,6 +2640,10 @@ if (!isset($_SESSION['user_id'])) {
} else {
preview = `${mediaIcon} ${conv.last_message || 'Archivo'}`;
}
} else if (conv.matched_content) {
// Mostrar snippet del mensaje que coincidió en la búsqueda
const snippet = this.truncateText(conv.matched_content, 55);
preview = `<span style="color:#888;font-size:11px;">💬 </span>${escapeHtml(snippet)}`;
} else {
preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
}
@@ -2652,30 +2692,32 @@ if (!isset($_SESSION['user_id'])) {
try {
const prevScrollTop = container.scrollTop;
const prevScrollHeight = container.scrollHeight;
const wasNearTop = prevScrollTop < 60;
console.log('🔄 Actualizando innerHTML del container...');
console.log('📏 Scroll antes - Top:', prevScrollTop, 'Height:', prevScrollHeight);
// Only reset to top if literally at position 0 (or within one pixel)
const wasAtTop = prevScrollTop <= 4;
container.innerHTML = html;
console.log('✅ DOM actualizado');
console.log('📊 Elementos en DOM:', container.children.length);
// Forzar repaint del DOM
void container.offsetHeight;
void container.offsetHeight; // forzar repaint
const newScrollHeight = container.scrollHeight;
const scrollDelta = newScrollHeight - prevScrollHeight;
console.log('📏 Scroll después - Height:', newScrollHeight, 'Delta:', scrollDelta);
if (wasNearTop) {
// keep at top
if (wasAtTop) {
container.scrollTop = 0;
} else {
// preserve visual offset (avoid jumping) whether the user is scrolling or not
container.scrollTop = Math.max(0, prevScrollTop + scrollDelta);
// Preservar posición visual exacta compensando cambio de altura
const restoredPos = Math.max(0, prevScrollTop + scrollDelta);
container.scrollTop = restoredPos;
// Si el ítem activo está ahora fuera de vista (más de 2 alturas de pantalla),
// acercarlo de forma suave sin regresar al tope
const activeItem = container.querySelector('.conversation-item.active');
if (activeItem) {
const rect = activeItem.getBoundingClientRect();
const listRect = container.getBoundingClientRect();
const isVisible = rect.top >= listRect.top && rect.bottom <= listRect.bottom;
if (!isVisible) {
activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
console.log('✅ renderConversations COMPLETADO');
@@ -2683,7 +2725,6 @@ if (!isset($_SESSION['user_id'])) {
// fallback to naive replace if anything failed
console.warn('renderConversations: scroll preservation failed', e);
container.innerHTML = html;
// Forzar repaint
void container.offsetHeight;
console.log('⚠️ renderConversations COMPLETADO con fallback');
}
@@ -5854,22 +5895,28 @@ if (!isset($_SESSION['user_id'])) {
thumb = '';
}
// Si no hay URL de media, mostrar placeholder con botón de descarga
// Si no hay URL de media, mostrar placeholder con botón de descarga inmediata
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
? `<a href="${escapeHtml(retryUrl)}" target="_blank" class="btn btn-sm btn-outline-success mt-1" onclick="event.stopPropagation(); setTimeout(()=>location.reload(), 3000);"><i class="fas fa-download"></i> Descargar ahora</a>`
: '';
const iconMap = {image: 'fa-image', video: 'fa-video', audio: 'fa-microphone', document: 'fa-file', sticker: 'fa-sticky-note'};
const icon = iconMap[mediaType] || 'fa-file';
const labelMap = {image: 'imagen', video: 'video', audio: 'audio', document: 'documento', sticker: 'sticker'};
const label = labelMap[mediaType] || mediaType;
const btnId = `dl-btn-${messageId}`;
const statusId = `dl-status-${messageId}`;
// Botón descarga on-demand
const hasId = messageId || (mediaUrl && /^\d+$/.test(mediaUrl));
const downloadBtn = hasId ? `
<button id="${escapeHtml(btnId)}" class="btn btn-sm btn-success mt-2" style="border-radius:20px; font-size:11px; padding:3px 12px;"
onclick="window.forceDownloadMedia(${escapeHtml(String(messageId))}, '${escapeHtml(btnId)}', '${escapeHtml(statusId)}'); return false;">
<i class='fas fa-download'></i> Descargar ahora
</button>
<div id="${escapeHtml(statusId)}" style="font-size:11px; color:#aaa; margin-top:4px;"></div>
` : '';
return `
<div class="message-media" style="background:rgba(0,0,0,0.04); border-radius:8px; padding:12px; text-align:center; min-width:180px;">
<i class="fas ${icon}" style="font-size:28px; color:#999; margin-bottom:6px;"></i>
<div style="font-size:12px; color:#888;">Descargando ${escapeHtml(mediaType)}...</div>
<div style="font-size:11px; color:#aaa; margin-top:2px;">En cola de descarga</div>
${retryBtn}
<i class="fas ${icon}" style="font-size:28px; color:#999; margin-bottom:6px; display:block;"></i>
<div style="font-size:12px; color:#888;">Pendiente de descarga (${escapeHtml(label)})</div>
${downloadBtn}
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
@@ -6044,6 +6091,50 @@ if (!isset($_SESSION['user_id'])) {
window.formatMessageContent = formatMessageContent;
/**
* Descarga inmediata on-demand de un archivo multimedia pendiente.
* Se llama desde el botón del placeholder de media pendiente.
*/
window.forceDownloadMedia = async function(messageId, btnId, statusId) {
const btn = document.getElementById(btnId);
const statusEl = document.getElementById(statusId);
if (!btn) return;
// Mostrar estado de carga
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Descargando...';
if (statusEl) statusEl.textContent = 'Solicitando descarga...';
try {
const resp = await fetch('api/force_download_media.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId })
});
const data = await resp.json();
if (data.success) {
if (statusEl) statusEl.innerHTML = '<span style="color:#25d366">&#10003; Descargado. Recargando...</span>';
// Recargar los mensajes después de 1 segundo para mostrar el archivo
setTimeout(() => {
if (window.chatApp && window.chatApp.currentUserId) {
window.chatApp.loadMessages(window.chatApp.currentUserId, true, true);
}
}, 1200);
} else {
const msg = data.message || data.error || 'No se pudo descargar ahora';
if (statusEl) statusEl.innerHTML = '<span style="color:#e74c3c">⚠️ ' + escapeHtml(msg) + '</span>';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo"></i> Reintentar';
}
} catch (err) {
console.error('forceDownloadMedia error', err);
if (statusEl) statusEl.innerHTML = '<span style="color:#e74c3c">⚠️ Error de conexión</span>';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo"></i> Reintentar';
}
};
// Delegate clicks on interactive buttons/list items to send quick replies
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('.wa-interactive-btn');
+84 -20
View File
@@ -421,6 +421,8 @@ class WhatsAppService
/**
* Subir archivo multimedia a WhatsApp
* Intenta primero con PHP curl. Si falla por SSL/conexión (errno 35, 56, 77),
* reintenta usando el CLI curl vía exec (mismo workaround que para descargas).
*/
public function uploadMedia($filePath, $mimeType)
{
@@ -430,6 +432,7 @@ class WhatsAppService
$url = $this->apiUrl . $this->phoneNumberId . '/media';
// ── Intento 1: PHP curl con opciones SSL relajadas ──────────────────────
$ch = curl_init();
$postFields = [
@@ -439,43 +442,104 @@ class WhatsAppService
];
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_HTTPHEADER => [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->token
],
CURLOPT_TIMEOUT => 300,
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_TIMEOUT => 300,
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
// Forzar TLS 1.2 (evita problemas de negociación SSL en algunos VPS)
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$curlErrno = curl_errno($ch);
curl_close($ch);
// Log detallado para diagnóstico
error_log("WhatsAppService::uploadMedia - URL: {$url}, HTTP: {$httpCode}, curlErrno: {$curlErrno}, curlError: {$curlError}, tokenLen: " . strlen($this->token) . ", phoneId: {$this->phoneNumberId}");
error_log("WhatsAppService::uploadMedia (php-curl) - URL: {$url}, HTTP: {$httpCode}, errno: {$curlErrno}, error: {$curlError}");
if ($curlErrno !== 0) {
// Errores de capa SSL/red que se benefician del fallback CLI
$sslErrors = [35, 56, 77, 58, 59, 60]; // CURLE_SSL_CONNECT_ERROR, CURLE_RECV_ERROR, etc.
$needsFallback = ($curlErrno !== 0 && in_array($curlErrno, $sslErrors, true));
if (!$needsFallback && $curlErrno !== 0) {
throw new Exception("Error de conexión subiendo archivo (curl #{$curlErrno}): {$curlError}");
}
if ($httpCode !== 200 && $httpCode !== 201) {
throw new Exception("Error subiendo archivo (HTTP {$httpCode}): " . $response);
if (!$needsFallback) {
if ($httpCode !== 200 && $httpCode !== 201) {
throw new Exception("Error subiendo archivo (HTTP {$httpCode}): " . $response);
}
$result = json_decode($response, true);
if (!isset($result['id'])) {
throw new Exception("No se obtuvo ID del archivo subido: " . $response);
}
return $result;
}
$result = json_decode($response, true);
// ── Intento 2: CLI curl (usa GnuTLS / sistema, evita bug OpenSSL de PHP) ─
error_log("WhatsAppService::uploadMedia: PHP curl falló (errno {$curlErrno}), usando curl CLI...");
if (!function_exists('exec') || !@exec('echo 1')) {
throw new Exception("Error de conexión subiendo archivo (curl #{$curlErrno}): {$curlError} — fallback CLI no disponible");
}
// Archivo temporal para la respuesta del CLI
$tmpResponse = tempnam(sys_get_temp_dir(), 'wa_upload_');
$cmd = sprintf(
'curl --silent --show-error'
. ' --ipv4'
. ' --tlsv1.2'
. ' --max-time 300'
. ' -o %s'
. ' -w "%%{http_code}"'
. ' -X POST'
. ' -H %s'
. ' -F %s'
. ' -F %s'
. ' -F %s'
. ' %s'
. ' 2>&1',
escapeshellarg($tmpResponse),
escapeshellarg('Authorization: Bearer ' . $this->token),
escapeshellarg('messaging_product=whatsapp'),
escapeshellarg('type=' . $mimeType),
escapeshellarg('file=@' . $filePath . ';type=' . $mimeType),
escapeshellarg($url)
);
$cliHttpCode = null;
$cliOut = null;
@exec($cmd, $outputLines, $exitCode);
$cliHttpCode = trim(implode('', $outputLines));
$cliBody = @file_get_contents($tmpResponse);
@unlink($tmpResponse);
error_log("WhatsAppService::uploadMedia (curl-cli) - exit: {$exitCode}, http: {$cliHttpCode}, body: " . substr((string)$cliBody, 0, 300));
if ($exitCode !== 0) {
throw new Exception("Error de conexión subiendo archivo (curl CLI exit {$exitCode}): {$cliHttpCode}");
}
$cliCode = intval($cliHttpCode);
if ($cliCode !== 200 && $cliCode !== 201) {
throw new Exception("Error subiendo archivo via CLI (HTTP {$cliCode}): " . $cliBody);
}
$result = json_decode($cliBody, true);
if (!isset($result['id'])) {
throw new Exception("No se obtuvo ID del archivo subido: " . $response);
throw new Exception("No se obtuvo ID del archivo subido (CLI): " . $cliBody);
}
return $result; // Retorna el objeto completo con 'id'
return $result;
}
/**