Files
whatsapp/services/WhatsAppService.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 4d04fea1de fix(turnero): save outgoing messages with canal='turnero' so they appear in chat
saveOutgoingMessage() now reads __app_meta.canal and stores it in conversations.
sendMediaById() accepts a $meta param and forwards it to saveOutgoingMessage.
chat_upload_media.php passes $extra (canal=turnero) to sendMediaById.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:32:23 -05:00

1149 lines
42 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
];
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_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
*/
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']);
}
// 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) {
$errorMsg = isset($decoded['error']['message']) ? $decoded['error']['message'] : 'Error desconocido';
// Debug detallado
error_log("WhatsApp API Error Details:");
error_log("- URL: " . $url);
error_log("- HTTP Code: " . $httpCode);
error_log("- Response: " . $response);
error_log("- Token (first 20 chars): " . substr($this->token, 0, 20) . "...");
throw new Exception("Error de WhatsApp API: " . $errorMsg);
}
return $decoded;
}
/**
* Formatear número de teléfono
*/
private function formatPhoneNumber($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)
];
}
}