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(); } /** * Enviar mensaje de texto */ public function sendTextMessage($to, $message) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'text', 'text' => [ 'body' => $message ] ]; return $this->sendMessage($data); } /** * Enviar mensaje con template */ public function sendTemplateMessage( $to, $templateName, $language = 'es', $bodyParameters = [], $headerParameters = [], $rawComponents = null ) { // 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 $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: convertir strings a objetos {type: 'text', text: '...'} if (!empty($bodyParameters)) { $normalizedBody = []; foreach ($bodyParameters as $bp) { if (is_array($bp) && isset($bp['type'])) { $normalizedBody[] = $bp; // ya en formato detallado } elseif (is_string($bp)) { $normalizedBody[] = [ 'type' => 'text', 'text' => $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 ]; return $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) { $video = ['link' => $videoUrl]; if ($caption) { $video['caption'] = $caption; } $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'video', 'video' => $video ]; return $this->sendMessage($data); } /** * Enviar audio */ public function sendAudioMessage($to, $audioUrl) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'audio', 'audio' => [ 'link' => $audioUrl ] ]; return $this->sendMessage($data); } /** * Enviar documento */ public function sendDocumentMessage($to, $documentUrl, $filename = null, $caption = null) { $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 ]; 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 => 120 ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200 && $httpCode !== 201) { throw new Exception("Error subiendo archivo: " . $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) */ public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = 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; } $data[$mediaType] = $mediaData; 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'; // Debug log error_log("WhatsApp API URL: " . $url); error_log("WhatsApp Token length: " . strlen($this->token)); error_log("WhatsApp Data: " . json_encode($data)); $response = $this->makeRequest('POST', $url, $data); // Guardar mensaje enviado en la base de datos (respuesta puede contener 'messages' o 'conversations') $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) { // 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 ]); if ($method === 'POST' && $data) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); // 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); } 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') ]; // Si es reply (context) if (isset($data['context']['message_id'])) { $messageData['reply_to_message_id'] = is_numeric($data['context']['message_id']) ? intval($data['context']['message_id']) : null; } // Si es reaction if ($data['type'] === 'reaction' && isset($data['reaction'])) { $messageData['reaction_to_message_id'] = is_numeric($data['reaction']['message_id'] ?? null) ? intval($data['reaction']['message_id']) : null; $messageData['reaction_emoji'] = $data['reaction']['emoji'] ?? null; } $this->db->insert('conversations', $messageData); // Si un asesor envía un mensaje, limpiar la solicitud de asesor (advisor_requested) try { $this->db->update('users', ['advisor_requested' => 0, 'on_hold' => 0, 'bot_paused_until' => null], 'id = :id', ['id' => $user['id']]); if (function_exists('writeLog')) writeLog('INFO', "Cleared advisor_requested for user {$user['id']} after outgoing message"); } 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'] : json_encode($data['reaction']); } return json_encode($data); } /** * 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) { $data = [ 'messaging_product' => 'whatsapp', 'to' => $this->formatPhoneNumber($to), 'type' => 'text', 'context' => [ 'message_id' => $messageId ], 'text' => [ 'preview_url' => (bool)$preview_url, 'body' => $text ] ]; 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) { $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 ]); $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) ]; } }