926 lines
32 KiB
PHP
926 lines
32 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()
|
|
{
|
|
// Obtener configuración desde base de datos
|
|
$config = getWhatsAppConfigFromDB();
|
|
|
|
$this->token = $config['token']; // Usar token de BD
|
|
$this->phoneNumberId = $config['phone_number_id']; // Usar phone_number_id de BD
|
|
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/'; // Usar api_url de BD
|
|
$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
|
|
*/
|
|
public function uploadMedia($filePath, $mimeType)
|
|
{
|
|
if (!file_exists($filePath)) {
|
|
throw new Exception("Archivo no encontrado: $filePath");
|
|
}
|
|
|
|
$url = $this->apiUrl . $this->phoneNumberId . '/media';
|
|
|
|
$ch = curl_init();
|
|
|
|
$postFields = [
|
|
'messaging_product' => 'whatsapp',
|
|
'file' => new CURLFile($filePath, $mimeType),
|
|
'type' => $mimeType
|
|
];
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $postFields,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $this->token
|
|
],
|
|
CURLOPT_TIMEOUT => 300,
|
|
CURLOPT_CONNECTTIMEOUT => 30,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
$curlErrno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
// Log detallado para diagnóstico
|
|
error_log("WhatsAppService::uploadMedia - URL: {$url}, HTTP: {$httpCode}, curlErrno: {$curlErrno}, curlError: {$curlError}, tokenLen: " . strlen($this->token) . ", phoneId: {$this->phoneNumberId}");
|
|
|
|
if ($curlErrno !== 0) {
|
|
throw new Exception("Error de conexión subiendo archivo (curl #{$curlErrno}): {$curlError}");
|
|
}
|
|
|
|
if ($httpCode !== 200 && $httpCode !== 201) {
|
|
throw new Exception("Error subiendo archivo (HTTP {$httpCode}): " . $response);
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
|
|
if (!isset($result['id'])) {
|
|
throw new Exception("No se obtuvo ID del archivo subido: " . $response);
|
|
}
|
|
|
|
return $result; // Retorna el objeto completo con 'id'
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
{
|
|
$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;
|
|
}
|
|
|
|
// NOTA: El parámetro 'ptt' (push-to-talk) para notas de voz NO está disponible
|
|
// en WhatsApp Cloud API cuando se usa media_id. Solo funciona en WhatsApp Business API On-Premise.
|
|
// Los mensajes de audio en formato .ogg con códec OPUS se mostrarán correctamente,
|
|
// pero sin la onda verde característica de las notas de voz.
|
|
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;
|
|
|
|
// Marcar para evitar guardado automático si se solicita
|
|
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,
|
|
]);
|
|
|
|
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) {
|
|
$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')
|
|
];
|
|
|
|
// 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,
|
|
]);
|
|
|
|
$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)
|
|
];
|
|
}
|
|
}
|