Files
whatsapp/services/WhatsAppService.php
T
Lizandro GuarnizoandClaude Opus 5 26025a6399 Reconocer por BSUID a quien oculta su teléfono, y poder pedírselo
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>
2026-08-11 20:07:52 -05:00

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)
];
}
}