Meta no permite averiguar el teléfono a partir del BSUID: no hay endpoint de consulta inversa, cada empresa debe llevar su propia equivalencia. Pero el BSUID llega en TODOS los webhooks de mensaje, también en los que aún traen teléfono, así que la equivalencia se puede ir guardando sola mientras la persona todavía muestra su número. - users.bsuid guarda esa equivalencia, y el webhook la anota en cada mensaje. Cuando alguien oculte su número, se le seguirá reconociendo y respondiendo a su teléfono de siempre. - Va en columna aparte y no en phone_number porque el teléfono además cruza con el paciente y el turnero; mezclarlos rompería esos cruces. - Para quien nunca escribió mostrando el número queda pedirle el contacto: pedirContacto() manda el botón request_contact_info, y el webhook atiende el mensaje `contacts` que llega si acepta. - Al vincular puede aparecer un segundo registro de la misma persona. No se fusionan: una fusión mal hecha mezcla dos historias clínicas. Gana el que tiene el teléfono, el otro queda anotado en el log para revisarlo a mano. - scripts/backfill_bsuid.php carga las equivalencias del histórico (6.423), con --simular para verlas sin escribir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1210 lines
44 KiB
PHP
1210 lines
44 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Servicio de WhatsApp - Envío de mensajes
|
|
* Fecha: 13 de noviembre de 2025
|
|
*/
|
|
|
|
// Asegurar que las funciones de configuración estén cargadas si este archivo
|
|
// se incluye directamente sin pasar por `config.php`.
|
|
if (!function_exists('getWhatsAppConfigFromDB')) {
|
|
$possible = __DIR__ . '/../config/config.php';
|
|
if (file_exists($possible)) {
|
|
require_once $possible;
|
|
}
|
|
}
|
|
|
|
class WhatsAppService
|
|
{
|
|
private $token;
|
|
private $phoneNumberId;
|
|
private $apiUrl;
|
|
private $db;
|
|
private $rateLimitMonitor;
|
|
|
|
public function __construct($canal = null)
|
|
{
|
|
// Obtener configuración desde base de datos
|
|
$config = getWhatsAppConfigFromDB();
|
|
|
|
$this->token = $config['token'];
|
|
$this->phoneNumberId = $config['phone_number_id'];
|
|
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/';
|
|
|
|
// Si se especifica canal 'turnero', usar el phone ID del número turnero
|
|
if ($canal === 'turnero') {
|
|
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
|
if ($turneroPhoneId) {
|
|
$this->phoneNumberId = $turneroPhoneId;
|
|
}
|
|
}
|
|
|
|
$this->db = Database::getInstance();
|
|
|
|
// Inicializar monitor de rate limit
|
|
try {
|
|
// Cargar autoloader si no está cargado
|
|
if (!class_exists('Predis\Client')) {
|
|
$autoloadPath = __DIR__ . '/../vendor/autoload.php';
|
|
if (file_exists($autoloadPath)) {
|
|
require_once $autoloadPath;
|
|
}
|
|
}
|
|
|
|
if (file_exists(__DIR__ . '/RateLimitMonitor.php')) {
|
|
require_once __DIR__ . '/RateLimitMonitor.php';
|
|
$this->rateLimitMonitor = new RateLimitMonitor();
|
|
}
|
|
} catch (Exception $e) {
|
|
error_log("WhatsAppService: No se pudo inicializar RateLimitMonitor - " . $e->getMessage());
|
|
$this->rateLimitMonitor = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje de texto
|
|
*/
|
|
public function sendTextMessage($to, $message, $meta = null)
|
|
{
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'text',
|
|
'text' => [
|
|
'body' => $message
|
|
]
|
|
];
|
|
|
|
if (!empty($meta) && is_array($meta)) {
|
|
// internal metadata used by our application (not sent to WhatsApp API)
|
|
$data['__app_meta'] = $meta;
|
|
}
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje con template
|
|
*/
|
|
public function sendTemplateMessage(
|
|
$to,
|
|
$templateName,
|
|
$language = 'es',
|
|
$bodyParameters = [],
|
|
$headerParameters = [],
|
|
$rawComponents = null,
|
|
$meta = null,
|
|
$dryRun = false
|
|
) {
|
|
// Si se pasan components completos (por ejemplo flow/button/image), úsalos tal cual
|
|
if (is_array($rawComponents) && !empty($rawComponents)) {
|
|
$template = [
|
|
'name' => $templateName,
|
|
'language' => [ 'code' => $language ],
|
|
'components' => $rawComponents
|
|
];
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'template',
|
|
'template' => $template
|
|
];
|
|
|
|
if (!empty($meta) && is_array($meta)) {
|
|
$data['__app_meta'] = $meta;
|
|
}
|
|
|
|
return $dryRun ? $data : $this->sendMessage($data);
|
|
}
|
|
|
|
$components = [];
|
|
|
|
// Normalizar parámetros de header (si vienen como strings -> convertir a objeto de texto)
|
|
if (!empty($headerParameters)) {
|
|
$normalizedHeader = [];
|
|
foreach ($headerParameters as $hp) {
|
|
if (is_array($hp) && isset($hp['type'])) {
|
|
$normalizedHeader[] = $hp;
|
|
} elseif (is_string($hp)) {
|
|
$normalizedHeader[] = [
|
|
'type' => 'text',
|
|
'text' => $hp
|
|
];
|
|
}
|
|
}
|
|
if (!empty($normalizedHeader)) {
|
|
$components[] = [
|
|
'type' => 'header',
|
|
'parameters' => $normalizedHeader
|
|
];
|
|
}
|
|
}
|
|
|
|
// Normalizar parámetros de body: soportar tres formatos:
|
|
// 1) Indexed array of strings: ['Alice','+123'] => converted to text params in order
|
|
// 2) Indexed array of param objects: [['type'=>'text','text'=>'Alice'], ...] => used as-is
|
|
// 3) Associative array for NAMED params: ['name' => 'Alice'] => converted to {type:text, name: 'name', text: 'Alice'}
|
|
if (!empty($bodyParameters)) {
|
|
$normalizedBody = [];
|
|
|
|
// Detect associative (named) arrays: if array has string keys
|
|
$isNamed = false;
|
|
foreach ($bodyParameters as $k => $v) {
|
|
if (!is_int($k)) { $isNamed = true; break; }
|
|
}
|
|
|
|
if ($isNamed) {
|
|
// bodyParameters is like ['name' => 'Alice', ...]
|
|
// Convert named params to the WhatsApp API expected parameter objects
|
|
foreach ($bodyParameters as $paramName => $paramValue) {
|
|
if (is_array($paramValue) && isset($paramValue['type'])) {
|
|
// already detailed param object: remove any unexpected keys like 'name'
|
|
$paramObj = $paramValue;
|
|
// ensure parameter name is set using 'parameter_name' (preferred) or fall back to 'name'
|
|
if (!isset($paramObj['parameter_name']) && !isset($paramObj['name'])) {
|
|
$paramObj['parameter_name'] = $paramName;
|
|
}
|
|
// remove legacy 'name' to avoid API errors
|
|
if (isset($paramObj['name'])) unset($paramObj['name']);
|
|
$normalizedBody[] = $paramObj;
|
|
} else {
|
|
// include parameter_name so WhatsApp recognizes named params
|
|
$normalizedBody[] = [
|
|
'type' => 'text',
|
|
'parameter_name' => $paramName,
|
|
'text' => (string)$paramValue
|
|
];
|
|
}
|
|
}
|
|
} else {
|
|
// Indexed array: preserve objects or convert strings
|
|
foreach ($bodyParameters as $bp) {
|
|
if (is_array($bp) && isset($bp['type'])) {
|
|
// ensure no unsupported keys like 'name' are passed through
|
|
$bpCopy = $bp;
|
|
unset($bpCopy['name']);
|
|
$normalizedBody[] = $bpCopy; // already detailed
|
|
} elseif (is_array($bp) && isset($bp['name'], $bp['value'])) {
|
|
// {name: 'param_name', value: 'text'} — include parameter_name for named-variable templates
|
|
$normalizedBody[] = [
|
|
'type' => 'text',
|
|
'parameter_name' => (string)$bp['name'],
|
|
'text' => (string)$bp['value'],
|
|
];
|
|
} elseif (is_string($bp) || is_numeric($bp)) {
|
|
$normalizedBody[] = [
|
|
'type' => 'text',
|
|
'text' => (string)$bp
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($normalizedBody)) {
|
|
$components[] = [
|
|
'type' => 'body',
|
|
'parameters' => $normalizedBody
|
|
];
|
|
}
|
|
}
|
|
|
|
$template = [
|
|
'name' => $templateName,
|
|
'language' => [
|
|
'code' => $language
|
|
]
|
|
];
|
|
|
|
if (!empty($components)) {
|
|
$template['components'] = $components;
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'template',
|
|
'template' => $template
|
|
];
|
|
|
|
if (!empty($meta) && is_array($meta)) {
|
|
// internal metadata used by our application (not sent to WhatsApp API)
|
|
$data['__app_meta'] = $meta;
|
|
}
|
|
|
|
return $dryRun ? $data : $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje con botones interactivos
|
|
*/
|
|
/**
|
|
* Pedirle a la persona que comparta su número de teléfono.
|
|
*
|
|
* Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él
|
|
* no se le puede cruzar con su historia ni con el turnero. Este botón es la
|
|
* vía que da Meta para pedírselo: la persona decide si lo comparte, y si
|
|
* acepta llega un mensaje de tipo `contacts` con el número.
|
|
*
|
|
* Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que
|
|
* usar una plantilla con el botón REQUEST_CONTACT_INFO.
|
|
*/
|
|
public function pedirContacto($to, $bodyText)
|
|
{
|
|
return $this->sendMessage([
|
|
'messaging_product' => 'whatsapp',
|
|
'recipient_type' => 'individual',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'interactive',
|
|
'interactive' => [
|
|
'type' => 'request_contact_info',
|
|
'body' => ['text' => $bodyText],
|
|
'action' => ['name' => 'request_contact_info'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
|
|
{
|
|
$interactive = [
|
|
'type' => 'button',
|
|
'body' => [
|
|
'text' => $bodyText
|
|
],
|
|
'action' => [
|
|
'buttons' => $buttons
|
|
]
|
|
];
|
|
|
|
if ($header) {
|
|
$interactive['header'] = [
|
|
'type' => 'text',
|
|
'text' => $header
|
|
];
|
|
}
|
|
|
|
if ($footer) {
|
|
$interactive['footer'] = [
|
|
'text' => $footer
|
|
];
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'recipient_type' => 'individual',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'interactive',
|
|
'interactive' => $interactive
|
|
];
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje con lista
|
|
*/
|
|
public function sendListMessage($to, $bodyText, $buttonText, $sections, $header = null, $footer = null)
|
|
{
|
|
$interactive = [
|
|
'type' => 'list',
|
|
'body' => [
|
|
'text' => $bodyText
|
|
],
|
|
'action' => [
|
|
'button' => $buttonText,
|
|
'sections' => $sections
|
|
]
|
|
];
|
|
|
|
if ($header) {
|
|
$interactive['header'] = [
|
|
'type' => 'text',
|
|
'text' => $header
|
|
];
|
|
}
|
|
|
|
if ($footer) {
|
|
$interactive['footer'] = [
|
|
'text' => $footer
|
|
];
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'recipient_type' => 'individual',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'interactive',
|
|
'interactive' => $interactive
|
|
];
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Marcar mensaje como leído
|
|
*/
|
|
public function markAsRead($messageId)
|
|
{
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'status' => 'read',
|
|
'message_id' => $messageId
|
|
];
|
|
|
|
$url = $this->apiUrl . $this->phoneNumberId . '/conversations';
|
|
return $this->makeRequest('POST', $url, $data);
|
|
}
|
|
|
|
/**
|
|
* Enviar imagen
|
|
*/
|
|
public function sendImageMessage($to, $imageUrl, $caption = null)
|
|
{
|
|
$image = ['link' => $imageUrl];
|
|
|
|
if ($caption) {
|
|
$image['caption'] = $caption;
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'image',
|
|
'image' => $image
|
|
];
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar video
|
|
*/
|
|
public function sendVideoMessage($to, $videoUrl, $caption = null, $skipAutoSave = false)
|
|
{
|
|
$video = ['link' => $videoUrl];
|
|
|
|
if ($caption) {
|
|
$video['caption'] = $caption;
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'video',
|
|
'video' => $video
|
|
];
|
|
|
|
if ($skipAutoSave) {
|
|
$data['__skip_auto_save'] = true;
|
|
}
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar audio
|
|
* @param string $to Número de destinatario
|
|
* @param string $audioUrl URL del audio
|
|
* @param bool $skipAutoSave Omitir guardado automático
|
|
* @param bool $isVoice True para enviar como nota de voz (requiere .ogg con códec OPUS)
|
|
*/
|
|
public function sendAudioMessage($to, $audioUrl, $skipAutoSave = false, $isVoice = false)
|
|
{
|
|
$audioData = [
|
|
'link' => $audioUrl
|
|
];
|
|
|
|
// Si es nota de voz, agregar parámetro ptt (push-to-talk)
|
|
// Requiere archivo .ogg con códec OPUS
|
|
if ($isVoice) {
|
|
// NOTA: WhatsApp Cloud API no tiene parámetro 'ptt' directo para links.
|
|
// Para notas de voz se recomienda subir el archivo y usar sendMediaById con is_voice.
|
|
error_log('sendAudioMessage: is_voice=true pero usando link. Para notas de voz, subir archivo primero.');
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'audio',
|
|
'audio' => $audioData
|
|
];
|
|
|
|
if ($skipAutoSave) {
|
|
$data['__skip_auto_save'] = true;
|
|
}
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar documento
|
|
*/
|
|
public function sendDocumentMessage($to, $documentUrl, $filename = null, $caption = null, $skipAutoSave = false)
|
|
{
|
|
$document = ['link' => $documentUrl];
|
|
|
|
if ($filename) {
|
|
$document['filename'] = $filename;
|
|
}
|
|
|
|
if ($caption) {
|
|
$document['caption'] = $caption;
|
|
}
|
|
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'document',
|
|
'document' => $document
|
|
];
|
|
|
|
if ($skipAutoSave) {
|
|
$data['__skip_auto_save'] = true;
|
|
}
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Subir archivo multimedia a WhatsApp
|
|
* Intenta primero con PHP curl. Si falla por SSL/conexión (errno 35, 56, 77),
|
|
* reintenta con distintas opciones SSL y usa stream_context/proc_open como fallback.
|
|
*/
|
|
public function uploadMedia($filePath, $mimeType)
|
|
{
|
|
if (!file_exists($filePath)) {
|
|
throw new Exception("Archivo no encontrado: $filePath");
|
|
}
|
|
|
|
$url = $this->apiUrl . $this->phoneNumberId . '/media';
|
|
$errors = [];
|
|
|
|
// ── Intento 1: PHP curl TLS 1.2 + sin ALPN + IPv4 (hasta 3 reintentos) ──
|
|
// curl1 logra conectar por TCP pero puede fallar en el envío (errno 55).
|
|
// Reintentar es útil: el error es del lado del CDN de Meta y suele desaparecer.
|
|
$a1Opts = [
|
|
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
|
CURLOPT_SSL_ENABLE_ALPN => false, // sin ALPN → HTTP/1.1 → CDN diferente (alcanzable)
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
];
|
|
$a1 = null;
|
|
for ($try = 1; $try <= 3; $try++) {
|
|
$a1 = $this->_curlUploadAttempt($url, $filePath, $mimeType, $a1Opts);
|
|
if ($a1['ok']) return $a1['result'];
|
|
error_log("WhatsAppService::uploadMedia a1 intento{$try} fail: errno={$a1['errno']} http={$a1['http']}");
|
|
if ($try < 3) sleep(1);
|
|
}
|
|
$errors[] = "curl1: #{$a1['errno']}({$a1['error']}) http={$a1['http']}";
|
|
|
|
// ── Intento 2: PHP curl SSL deshabilitado + sin ALPN + HTTP/1.1 ──────────
|
|
// IMPORTANTE: ALPN desactivado es crítico — con ALPN activo curl resuelve a un
|
|
// IP de graph.facebook.com que no tiene ruta en el contenedor (errno 7 inmediato).
|
|
$a2 = $this->_curlUploadAttempt($url, $filePath, $mimeType, [
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
CURLOPT_SSL_VERIFYHOST => 0,
|
|
CURLOPT_SSL_ENABLE_ALPN => false, // mismo truco que curl1
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
]);
|
|
if ($a2['ok']) return $a2['result'];
|
|
$errors[] = "curl2: #{$a2['errno']}({$a2['error']}) http={$a2['http']}";
|
|
error_log("WhatsAppService::uploadMedia a2 fail: {$errors[1]}");
|
|
|
|
// ── Intento 3: PHP stream_context (evita libcurl/OpenSSL completamente) ──
|
|
// Usa el wrapper SSL de PHP (diferente al de libcurl), sin ALPN issues
|
|
try {
|
|
$result = $this->_streamContextUpload($url, $filePath, $mimeType);
|
|
if ($result) return $result;
|
|
} catch (Exception $e) {
|
|
$errors[] = "stream: " . $e->getMessage();
|
|
error_log("WhatsAppService::uploadMedia stream fail: " . $e->getMessage());
|
|
}
|
|
|
|
// ── Intento 4: proc_open + curl CLI con --ipv4 ──────────────────────────
|
|
if (function_exists('proc_open')) {
|
|
try {
|
|
$result = $this->_procOpenUpload($url, $filePath, $mimeType);
|
|
if ($result) return $result;
|
|
} catch (Exception $e) {
|
|
$errors[] = "proc_open: " . $e->getMessage();
|
|
error_log("WhatsAppService::uploadMedia proc_open fail: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
throw new Exception(
|
|
"Error al subir archivo a WhatsApp tras 4 intentos: " . implode(' | ', $errors) .
|
|
". Solución permanente: docker compose up --build (aplica parche OpenSSL en contenedor)."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Helper interno: upload con PHP curl + opciones extra.
|
|
* IMPORTANTE: $extraOpts siempre sobrescriben los base. IPv4 se fuerza en base.
|
|
*/
|
|
private function _curlUploadAttempt($url, $filePath, $mimeType, array $extraOpts = [])
|
|
{
|
|
$ch = curl_init();
|
|
// Base siempre incluye IPRESOLVE_V4; extraOpts puede sobreescribirlo si es necesario
|
|
$base = [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => [
|
|
'messaging_product' => 'whatsapp',
|
|
'file' => new CURLFile($filePath, $mimeType),
|
|
'type' => $mimeType,
|
|
],
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $this->token,
|
|
'Expect:', // deshabilitar 100-Continue: evita errno 55 (Send failure) con Meta
|
|
],
|
|
CURLOPT_TIMEOUT => 300,
|
|
CURLOPT_CONNECTTIMEOUT => 30,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, // siempre IPv4 salvo que extraOpts lo cambie
|
|
];
|
|
curl_setopt_array($ch, $base + $extraOpts);
|
|
|
|
$response = curl_exec($ch);
|
|
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
$errno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
if ($errno !== 0 || ($http !== 200 && $http !== 201)) {
|
|
return ['ok' => false, 'errno' => $errno, 'error' => $error, 'http' => $http];
|
|
}
|
|
$result = json_decode($response, true);
|
|
if (!isset($result['id'])) {
|
|
return ['ok' => false, 'errno' => 0, 'error' => 'No ID: ' . substr($response, 0, 200), 'http' => $http];
|
|
}
|
|
return ['ok' => true, 'result' => $result];
|
|
}
|
|
|
|
/**
|
|
* Upload usando PHP stream_context (evita libcurl y su OpenSSL completamente).
|
|
* Construye manualmente el body multipart/form-data.
|
|
*/
|
|
private function _streamContextUpload($url, $filePath, $mimeType)
|
|
{
|
|
$boundary = '----WA' . bin2hex(random_bytes(16));
|
|
$fileContent = file_get_contents($filePath);
|
|
if ($fileContent === false) {
|
|
throw new Exception("No se pudo leer el archivo: $filePath");
|
|
}
|
|
$filename = basename($filePath);
|
|
|
|
// Construir multipart body manualmente
|
|
$body = "--{$boundary}\r\n";
|
|
$body .= "Content-Disposition: form-data; name=\"messaging_product\"\r\n\r\n";
|
|
$body .= "whatsapp\r\n";
|
|
$body .= "--{$boundary}\r\n";
|
|
$body .= "Content-Disposition: form-data; name=\"type\"\r\n\r\n";
|
|
$body .= "{$mimeType}\r\n";
|
|
$body .= "--{$boundary}\r\n";
|
|
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
|
|
$body .= "Content-Type: {$mimeType}\r\n\r\n";
|
|
$body .= $fileContent . "\r\n";
|
|
$body .= "--{$boundary}--\r\n";
|
|
|
|
$opts = [
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => implode("\r\n", [
|
|
'Authorization: Bearer ' . $this->token,
|
|
'Content-Type: multipart/form-data; boundary=' . $boundary,
|
|
'Content-Length: ' . strlen($body),
|
|
]),
|
|
'content' => $body,
|
|
'timeout' => 300,
|
|
'ignore_errors' => true,
|
|
'protocol_version' => '1.1',
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
'verify_peer_name' => true,
|
|
'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
|
|
'cafile' => '/etc/ssl/certs/ca-certificates.crt',
|
|
],
|
|
];
|
|
|
|
$ctx = stream_context_create($opts);
|
|
$response = @file_get_contents($url, false, $ctx);
|
|
|
|
// Obtener HTTP code de los response headers
|
|
$httpCode = 0;
|
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
|
foreach ($http_response_header as $h) {
|
|
if (preg_match('#^HTTP/\S+ (\d+)#', $h, $m)) {
|
|
$httpCode = intval($m[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
error_log("WhatsAppService::_streamContextUpload http={$httpCode} response=" . substr((string)$response, 0, 300));
|
|
|
|
if ($response === false || ($httpCode !== 200 && $httpCode !== 201)) {
|
|
$err = error_get_last();
|
|
throw new Exception("stream_context HTTP {$httpCode}: " . ($err['message'] ?? 'sin respuesta'));
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
if (!isset($result['id'])) {
|
|
throw new Exception("stream_context sin ID: " . substr($response, 0, 300));
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Upload usando proc_open + curl CLI con --ipv4 explícito.
|
|
* proc_open hereda el entorno de red completo del proceso PHP-FPM.
|
|
*/
|
|
private function _procOpenUpload($url, $filePath, $mimeType)
|
|
{
|
|
// Localizar curl binary
|
|
$curlBin = '/usr/bin/curl';
|
|
if (!file_exists($curlBin)) {
|
|
foreach (['/usr/local/bin/curl', '/bin/curl'] as $c) {
|
|
if (file_exists($c)) { $curlBin = $c; break; }
|
|
}
|
|
}
|
|
if (!file_exists($curlBin)) {
|
|
throw new Exception("curl CLI no encontrado");
|
|
}
|
|
|
|
$tmpResponse = tempnam(sys_get_temp_dir(), 'wa_up_');
|
|
|
|
$args = [
|
|
$curlBin,
|
|
'--silent', '--show-error',
|
|
'--ipv4', // forzar IPv4
|
|
'--tlsv1.2',
|
|
'--http1.1', // sin ALPN→HTTP/2: graph.facebook.com CDN alcanzable
|
|
'--no-alpn', // desactiva ALPN extension (igual que CURLOPT_SSL_ENABLE_ALPN=false)
|
|
'--insecure', // bypass SSL verify (fallback de último recurso)
|
|
'--max-time', '300',
|
|
'-o', $tmpResponse,
|
|
'-w', '%{http_code}',
|
|
'-X', 'POST',
|
|
'-H', 'Authorization: Bearer ' . $this->token,
|
|
'-H', 'Expect:', // deshabilitar 100-Continue: evita errno 55 con Meta API
|
|
'-F', 'messaging_product=whatsapp',
|
|
'-F', 'type=' . $mimeType,
|
|
'-F', 'file=@' . $filePath . ';type=' . $mimeType,
|
|
$url,
|
|
];
|
|
|
|
$cmd = implode(' ', array_map('escapeshellarg', $args));
|
|
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
|
$env = array_merge(
|
|
$_ENV ?: [],
|
|
['HOME' => '/tmp', 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']
|
|
);
|
|
$proc = proc_open($cmd, $descriptors, $pipes, null, $env);
|
|
|
|
if (!is_resource($proc)) {
|
|
@unlink($tmpResponse);
|
|
throw new Exception("proc_open no pudo iniciar el proceso");
|
|
}
|
|
|
|
fclose($pipes[0]);
|
|
$stdout = stream_get_contents($pipes[1]);
|
|
$stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]);
|
|
fclose($pipes[2]);
|
|
$exitCode = proc_close($proc);
|
|
|
|
$httpCode = intval(trim($stdout));
|
|
$body = @file_get_contents($tmpResponse);
|
|
@unlink($tmpResponse);
|
|
|
|
error_log("WhatsAppService::_procOpenUpload exit={$exitCode} http={$httpCode} stderr=" . substr($stderr, 0, 300));
|
|
|
|
if ($exitCode !== 0) {
|
|
throw new Exception("curl CLI exit {$exitCode}: " . trim($stderr));
|
|
}
|
|
if ($httpCode !== 200 && $httpCode !== 201) {
|
|
throw new Exception("CLI HTTP {$httpCode}: " . substr((string)$body, 0, 300));
|
|
}
|
|
$result = json_decode($body, true);
|
|
if (!isset($result['id'])) {
|
|
throw new Exception("CLI sin ID: " . substr((string)$body, 0, 300));
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje usando media_id (archivo ya subido a WhatsApp)
|
|
* @param string $to Número de destinatario
|
|
* @param string $mediaId ID del archivo en WhatsApp
|
|
* @param string $mediaType Tipo: image, video, audio, document
|
|
* @param string|null $caption Texto opcional (no aplica para audio)
|
|
* @param string|null $filename Nombre archivo (solo document)
|
|
* @param bool $skipAutoSave Omitir guardado automático
|
|
* @param bool $isVoice Para audio: true = nota de voz con onda verde, false = audio normal
|
|
*/
|
|
public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false, $meta = null)
|
|
{
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => $mediaType
|
|
];
|
|
|
|
$mediaData = ['id' => $mediaId];
|
|
|
|
if ($caption && in_array($mediaType, ['image', 'video', 'document'])) {
|
|
$mediaData['caption'] = $caption;
|
|
}
|
|
|
|
if ($filename && $mediaType === 'document') {
|
|
$mediaData['filename'] = $filename;
|
|
}
|
|
|
|
if ($mediaType === 'audio' && $isVoice) {
|
|
error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.');
|
|
}
|
|
|
|
$data[$mediaType] = $mediaData;
|
|
|
|
if (!empty($meta) && is_array($meta)) {
|
|
$data['__app_meta'] = $meta;
|
|
}
|
|
|
|
if ($skipAutoSave) {
|
|
$data['__skip_auto_save'] = true;
|
|
}
|
|
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje principal
|
|
*/
|
|
private function sendMessage($data)
|
|
{
|
|
// Construir URL correcto para enviar mensajes (use /messages)
|
|
$url = rtrim($this->apiUrl, '/') . '/' . $this->phoneNumberId . '/messages';
|
|
|
|
// Prepare payload (remove internal app meta before sending to WhatsApp API)
|
|
$payload = $data;
|
|
if (isset($payload['__app_meta'])) {
|
|
unset($payload['__app_meta']);
|
|
}
|
|
|
|
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
|
|
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
|
|
// todos los envíos, en vez de en cada método que arma un payload.
|
|
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
|
|
$payload['recipient'] = $payload['to'];
|
|
unset($payload['to']);
|
|
}
|
|
|
|
// Debug log (payload sent to WhatsApp)
|
|
error_log("WhatsApp API URL: " . $url);
|
|
error_log("WhatsApp Token length: " . strlen($this->token));
|
|
error_log("WhatsApp Payload: " . json_encode($payload));
|
|
|
|
$response = $this->makeRequest('POST', $url, $payload);
|
|
|
|
// Guardar mensaje enviado en la base de datos (respuesta puede contener 'messages' o 'conversations')
|
|
// Solo guardar si no se solicitó skipAutoSave
|
|
$skipAutoSave = isset($data['__skip_auto_save']) && $data['__skip_auto_save'];
|
|
|
|
$sentMessageId = null;
|
|
if ($response) {
|
|
if (isset($response['messages'][0]['id'])) {
|
|
$sentMessageId = $response['messages'][0]['id'];
|
|
} elseif (isset($response['conversations'][0]['id'])) {
|
|
$sentMessageId = $response['conversations'][0]['id'];
|
|
}
|
|
}
|
|
|
|
if ($response && $sentMessageId && !$skipAutoSave) {
|
|
// Asegurar que la estructura de respuesta para saveOutgoingMessage se mantenga pasando el id
|
|
$responseWrapper = $response;
|
|
// Normalizar para compatibilidad con saveOutgoingMessage
|
|
if (!isset($responseWrapper['conversations']) && $sentMessageId) {
|
|
$responseWrapper['conversations'][0]['id'] = $sentMessageId;
|
|
}
|
|
$this->saveOutgoingMessage($data, $responseWrapper);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Realizar petición HTTP
|
|
*/
|
|
private function makeRequest($method, $url, $data = null)
|
|
{
|
|
$ch = curl_init();
|
|
|
|
$headers = [
|
|
'Authorization: Bearer ' . $this->token,
|
|
'Content-Type: application/json',
|
|
'User-Agent: WhatsApp-Bot/1.0'
|
|
];
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 3,
|
|
CURLOPT_HEADER => true, // Capturar headers de respuesta
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
// Forzar TLS 1.2: evita el bug errno-56 de OpenSSL 3.x con Meta/Facebook CDN
|
|
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
|
]);
|
|
|
|
if ($method === 'POST' && $data) {
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
|
}
|
|
|
|
$fullResponse = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
|
$error = curl_error($ch);
|
|
|
|
// Separar headers y body
|
|
$headerText = substr($fullResponse, 0, $headerSize);
|
|
$response = substr($fullResponse, $headerSize);
|
|
|
|
// curl_close is deprecated in PHP 8.5+ (has no effect). Avoid calling it on newer versions.
|
|
if (version_compare(PHP_VERSION, '8.5.0', '<')) {
|
|
curl_close($ch);
|
|
}
|
|
|
|
// Capturar rate limit info de los headers
|
|
if ($this->rateLimitMonitor && $headerText) {
|
|
$endpoint = parse_url($url, PHP_URL_PATH);
|
|
// Parsear headers string a array
|
|
$headersArray = array_filter(explode("\r\n", $headerText));
|
|
$this->rateLimitMonitor->captureFromHeaders($headersArray, $endpoint);
|
|
}
|
|
|
|
if ($error) {
|
|
error_log("cURL Error: " . $error);
|
|
throw new Exception("Error de comunicación con WhatsApp: " . $error);
|
|
}
|
|
|
|
$decoded = json_decode($response, true);
|
|
|
|
if ($httpCode >= 400) {
|
|
$err = $decoded['error'] ?? [];
|
|
$errorMsg = $err['message'] ?? 'Error desconocido';
|
|
$details = $err['error_data']['details'] ?? ($err['error_data']['description'] ?? null);
|
|
$subcode = $err['error_subcode'] ?? null;
|
|
|
|
error_log("WhatsApp API Error: HTTP={$httpCode} code={$err['code']} sub={$subcode} msg={$errorMsg} details={$details} body=" . substr($response, 0, 400));
|
|
|
|
$full = $errorMsg;
|
|
if ($subcode) $full .= " (sub:{$subcode})";
|
|
if ($details) $full .= " — {$details}";
|
|
throw new Exception("Error de WhatsApp API: " . $full);
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
|
* Formato de Meta: código de país, punto y dígitos. Ej.: "CO.1761088155094242".
|
|
*/
|
|
public static function esBsuid($destinatario)
|
|
{
|
|
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $destinatario);
|
|
}
|
|
|
|
/**
|
|
* Formatear número de teléfono
|
|
*/
|
|
private function formatPhoneNumber($phone)
|
|
{
|
|
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
|
|
// el punto y las letras lo convertiría en un destinatario inexistente.
|
|
if (self::esBsuid($phone)) {
|
|
return $phone;
|
|
}
|
|
|
|
// Remover caracteres especiales
|
|
$phone = preg_replace('/[^0-9]/', '', $phone);
|
|
|
|
// Si empieza con +57 (Colombia), mantenerlo
|
|
if (substr($phone, 0, 2) === '57' && strlen($phone) >= 12) {
|
|
return $phone;
|
|
}
|
|
|
|
// Si es número colombiano sin código de país
|
|
if (strlen($phone) === 10 && substr($phone, 0, 1) === '3') {
|
|
return '57' . $phone;
|
|
}
|
|
|
|
return $phone;
|
|
}
|
|
|
|
/**
|
|
* Guardar mensaje enviado en BD
|
|
*/
|
|
private function saveOutgoingMessage($data, $response)
|
|
{
|
|
try {
|
|
$user = $this->getUserByPhone($data['to']);
|
|
if ($user) {
|
|
$appMeta = $data['__app_meta'] ?? [];
|
|
$messageData = [
|
|
'user_id' => $user['id'],
|
|
'message_id' => $response['conversations'][0]['id'],
|
|
'direction' => 'outgoing',
|
|
'message_type' => $data['type'],
|
|
'content' => $this->extractMessageContent($data),
|
|
'status' => 'sent',
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'canal' => $appMeta['canal'] ?? 'bot',
|
|
];
|
|
|
|
// Para mensajes multimedia, guardar el media_id si está disponible
|
|
if (in_array($data['type'], ['image', 'video', 'audio', 'document'])) {
|
|
$mediaId = $data[$data['type']]['id'] ?? null;
|
|
$mediaLink = $data[$data['type']]['link'] ?? null;
|
|
|
|
// Prioridad: usar media_id de WhatsApp, sino link
|
|
if ($mediaId) {
|
|
$messageData['media_url'] = $mediaId;
|
|
$messageData['whatsapp_media_id'] = $mediaId;
|
|
} elseif ($mediaLink) {
|
|
$messageData['media_url'] = $mediaLink;
|
|
}
|
|
|
|
// Asegurar que content tenga el filename si está disponible
|
|
$filename = $data[$data['type']]['filename'] ?? null;
|
|
if ($filename) {
|
|
// Si content está vacío o es una descripción genérica, usar filename
|
|
if (empty($messageData['content']) ||
|
|
in_array($messageData['content'], ['[Imagen]', '[Video]', '[Audio]', '[Documento]'])) {
|
|
$messageData['content'] = $filename;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log detallado para multimedia
|
|
if (in_array($data['type'], ['image', 'video', 'audio', 'document'])) {
|
|
error_log("WhatsAppService.saveOutgoingMessage - Multimedia:");
|
|
error_log(" - type: " . $data['type']);
|
|
error_log(" - content: " . ($messageData['content'] ?? 'empty'));
|
|
error_log(" - media_url: " . ($messageData['media_url'] ?? 'empty'));
|
|
error_log(" - whatsapp_media_id: " . ($messageData['whatsapp_media_id'] ?? 'empty'));
|
|
}
|
|
|
|
// Si es reply (context)
|
|
if (isset($data['context']['message_id'])) {
|
|
// WhatsApp message IDs can be alphanumeric (e.g., wamid...), store as string
|
|
$messageData['reply_to_message_id'] = trim((string)$data['context']['message_id']) ?: null;
|
|
}
|
|
|
|
// Si es reaction
|
|
if ($data['type'] === 'reaction' && isset($data['reaction'])) {
|
|
$messageData['reaction_to_message_id'] = isset($data['reaction']['message_id']) ? trim((string)$data['reaction']['message_id']) : null;
|
|
$messageData['reaction_emoji'] = $data['reaction']['emoji'] ?? null;
|
|
}
|
|
|
|
$this->db->insert('conversations', $messageData);
|
|
|
|
// Solo limpiar advisor_requested si el mensaje fue enviado por un asesor (operator)
|
|
try {
|
|
$cleared = false;
|
|
if (isset($data['__app_meta']) && is_array($data['__app_meta']) && !empty($data['__app_meta']['operator_id'])) {
|
|
$this->db->update('users', ['advisor_requested' => 0, 'on_hold' => 0, 'bot_paused_until' => null], 'id = :id', ['id' => $user['id']]);
|
|
$cleared = true;
|
|
}
|
|
if ($cleared && function_exists('writeLog')) writeLog('INFO', "Cleared advisor_requested for user {$user['id']} after outgoing message by operator");
|
|
} catch (Exception $e) {
|
|
error_log('Failed to clear advisor_requested after outgoing: ' . $e->getMessage());
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
error_log("Error saving outgoing message: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extraer contenido del mensaje para guardar
|
|
*/
|
|
private function extractMessageContent($data)
|
|
{
|
|
switch ($data['type']) {
|
|
case 'text':
|
|
return $data['text']['body'];
|
|
case 'template':
|
|
return 'Template: ' . $data['template']['name'];
|
|
case 'interactive':
|
|
if (isset($data['interactive']['body']['text'])) {
|
|
return $data['interactive']['body']['text'];
|
|
}
|
|
break;
|
|
case 'reaction':
|
|
return isset($data['reaction']['emoji']) ? $data['reaction']['emoji'] : '';
|
|
case 'image':
|
|
// Prioridad: caption, filename, descripción por defecto
|
|
if (!empty($data['image']['caption'])) {
|
|
return $data['image']['caption'];
|
|
}
|
|
if (!empty($data['image']['filename'])) {
|
|
return $data['image']['filename'];
|
|
}
|
|
return '[Imagen]';
|
|
case 'video':
|
|
if (!empty($data['video']['caption'])) {
|
|
return $data['video']['caption'];
|
|
}
|
|
if (!empty($data['video']['filename'])) {
|
|
return $data['video']['filename'];
|
|
}
|
|
return '[Video]';
|
|
case 'audio':
|
|
if (!empty($data['audio']['filename'])) {
|
|
return $data['audio']['filename'];
|
|
}
|
|
if (!empty($data['audio']['caption'])) {
|
|
return $data['audio']['caption'];
|
|
}
|
|
return '[Audio]';
|
|
case 'document':
|
|
// Prioridad: filename, caption, descripción por defecto
|
|
if (!empty($data['document']['filename'])) {
|
|
return $data['document']['filename'];
|
|
}
|
|
if (!empty($data['document']['caption'])) {
|
|
return $data['document']['caption'];
|
|
}
|
|
return '[Documento]';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Enviar reacción (emoji) a un mensaje previo
|
|
* @param string $to Teléfono del destinatario
|
|
* @param string $messageId ID del mensaje al que se reacciona
|
|
* @param string $emoji Emoji de reacción
|
|
* @param bool $dryRun Si true, no hace la petición y devuelve el payload
|
|
*/
|
|
public function sendReaction($to, $messageId, $emoji, $dryRun = false)
|
|
{
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'reaction',
|
|
'reaction' => [
|
|
'message_id' => $messageId,
|
|
'emoji' => $emoji
|
|
]
|
|
];
|
|
|
|
if ($dryRun) return $data;
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Enviar texto como respuesta (con contexto) a un mensaje existente
|
|
* @param string $to Teléfono del destinatario
|
|
* @param string $messageId ID del mensaje al que se responde
|
|
* @param string $text Contenido del mensaje de respuesta
|
|
* @param bool $preview_url Incluir preview_url en body.text
|
|
* @param bool $dryRun Si true, no hace la petición y devuelve el payload
|
|
*/
|
|
public function sendTextReply($to, $messageId, $text, $preview_url = false, $dryRun = false, $meta = null)
|
|
{
|
|
$data = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->formatPhoneNumber($to),
|
|
'type' => 'text',
|
|
'context' => [
|
|
'message_id' => $messageId
|
|
],
|
|
'text' => [
|
|
'preview_url' => (bool)$preview_url,
|
|
'body' => $text
|
|
]
|
|
];
|
|
|
|
if (!empty($meta) && is_array($meta)) {
|
|
$data['__app_meta'] = $meta;
|
|
}
|
|
|
|
if ($dryRun) return $data;
|
|
return $this->sendMessage($data);
|
|
}
|
|
|
|
/**
|
|
* Obtener usuario por teléfono
|
|
*/
|
|
private function getUserByPhone($phone)
|
|
{
|
|
return $this->db->fetch(
|
|
"SELECT * FROM users WHERE phone_number = :phone",
|
|
['phone' => $phone]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Descargar archivo multimedia
|
|
*/
|
|
public function downloadMedia($mediaId)
|
|
{
|
|
// Validar que mediaId no esté vacío ANTES de hacer la llamada
|
|
if (empty($mediaId) || !is_string($mediaId) || trim($mediaId) === '') {
|
|
error_log("downloadMedia: mediaId inválido o vacío: " . var_export($mediaId, true));
|
|
throw new Exception("Media ID no puede estar vacío");
|
|
}
|
|
|
|
$url = $this->apiUrl . $mediaId;
|
|
|
|
// Primero obtener información del archivo
|
|
$mediaInfo = $this->makeRequest('GET', $url);
|
|
|
|
if (!$mediaInfo || !isset($mediaInfo['url'])) {
|
|
throw new Exception("No se pudo obtener información del archivo");
|
|
}
|
|
|
|
// Descargar el archivo
|
|
$fileUrl = $mediaInfo['url'];
|
|
$ch = curl_init();
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $fileUrl,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $this->token
|
|
],
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
|
|
]);
|
|
|
|
$fileContent = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode !== 200) {
|
|
throw new Exception("Error descargando archivo multimedia");
|
|
}
|
|
|
|
return [
|
|
'content' => $fileContent,
|
|
'mime_type' => $mediaInfo['mime_type'] ?? 'application/octet-stream',
|
|
'file_size' => $mediaInfo['file_size'] ?? strlen($fileContent)
|
|
];
|
|
}
|
|
}
|