diff --git a/api/send_media_message.php b/api/send_media_message.php index 700e052..af65737 100644 --- a/api/send_media_message.php +++ b/api/send_media_message.php @@ -66,9 +66,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { } // Función auxiliar para enviar con media_id -function sendMediaByIdToWhatsApp($whatsapp, $recipient, $mediaId, $mediaType, $caption, $filename) { +function sendMediaByIdToWhatsApp($whatsapp, $recipient, $mediaId, $mediaType, $caption, $filename, $isVoice = false) { // Pasar skipAutoSave=true para evitar guardado duplicado (lo guardamos manualmente después) - return $whatsapp->sendMediaById($recipient, $mediaId, $mediaType, $caption, $filename, true); + // Para audio: pasar is_voice para determinar si es nota de voz o audio normal + return $whatsapp->sendMediaById($recipient, $mediaId, $mediaType, $caption, $filename, true, $isVoice); } // Función auxiliar para enviar con link @@ -151,6 +152,12 @@ try { $mediaType = $input['media_type']; // image, video, audio, document $caption = $input['caption'] ?? null; $filename = $input['filename'] ?? null; + // Para audio: is_voice = true significa nota de voz (grabada desde micrófono) + // is_voice = false o ausente significa audio normal (archivo subido) + $isVoice = isset($input['is_voice']) ? (bool)$input['is_voice'] : false; + + error_log("send_media_message.php - is_voice: " . ($isVoice ? 'true' : 'false')); + smm_log("is_voice: " . ($isVoice ? 'true' : 'false')); // Inicializar servicio de WhatsApp $whatsapp = new WhatsAppService(); @@ -216,8 +223,8 @@ try { error_log("send_media_message.php - Media ID obtenido: " . $mediaId); smm_log("Media ID obtained: " . $mediaId); - // Enviar mensaje usando media_id - $response = sendMediaByIdToWhatsApp($whatsapp, $recipient, $mediaId, $mediaType, $caption, $filename); + // Enviar mensaje usando media_id (pasar isVoice para audio) + $response = sendMediaByIdToWhatsApp($whatsapp, $recipient, $mediaId, $mediaType, $caption, $filename, $isVoice); smm_log("Send media by id response: " . json_encode($response)); } else { smm_log("Upload did not return id: " . json_encode($uploadResult)); @@ -276,7 +283,7 @@ try { if (isset($uploadResult['id'])) { $uploaded_media_id = $uploadResult['id']; smm_log('Uploaded downloaded audio to WhatsApp, media_id: ' . $uploaded_media_id); - $response = sendMediaByIdToWhatsApp($whatsapp, $recipient, $uploaded_media_id, $mediaType, $caption, $filename); + $response = sendMediaByIdToWhatsApp($whatsapp, $recipient, $uploaded_media_id, $mediaType, $caption, $filename, $isVoice); } else { smm_log('Upload of downloaded audio did not return id, fallback to link.'); $response = sendMediaByLinkToWhatsApp($whatsapp, $recipient, $mediaUrl, $mediaType, $caption, $filename); diff --git a/api/upload_media.php b/api/upload_media.php index b1eedb2..2218f5f 100644 --- a/api/upload_media.php +++ b/api/upload_media.php @@ -108,11 +108,16 @@ try { if ($ffmpeg) { try { if (strpos($fileType, 'audio/webm') === 0) { - // Audio WebM -> OGG/Opus + // Audio WebM -> OGG/Opus (Compatible con WhatsApp Voice Messages) $convertedName = uniqid('media_', true) . '.ogg'; $convertedPath = $uploadDir . $convertedName; - // Convertir a Opus (bitrate moderado). Forzamos formato OGG, mono 48kHz y mapeamos la primera pista de audio. - $cmd = escapeshellcmd($ffmpeg) . ' -y -i ' . escapeshellarg($uploadPath) . ' -map 0:a:0 -c:a libopus -b:a 64k -vbr on -ar 48000 -ac 1 -f ogg ' . escapeshellarg($convertedPath) . ' 2>&1'; + // Parámetros WhatsApp Voice: + // -c:a libopus = códec OPUS (requerido para transcripción) + // -b:a 32k = bitrate bajo para mantener archivo < 512KB (ícono play) + // -ar 48000 = sample rate 48kHz (estándar OPUS) + // -ac 1 = mono (reduce tamaño) + // -vbr on = variable bitrate + $cmd = escapeshellcmd($ffmpeg) . ' -y -i ' . escapeshellarg($uploadPath) . ' -map 0:a:0 -c:a libopus -b:a 32k -vbr on -ar 48000 -ac 1 -f ogg ' . escapeshellarg($convertedPath) . ' 2>&1'; $newType = 'audio/ogg'; $logType = 'Audio'; } else { diff --git a/assets/js/chat-common.js b/assets/js/chat-common.js index c0910f0..5310d3a 100644 --- a/assets/js/chat-common.js +++ b/assets/js/chat-common.js @@ -219,10 +219,16 @@ async function convertWebmToOgg(file) { if (_ffmpegInstance._fetchFile) data = await _ffmpegInstance._fetchFile(file); else data = new Uint8Array(await file.arrayBuffer()); _ffmpegInstance.FS('writeFile', inName, data); - await _ffmpegInstance.run('-i', inName, '-c:a', 'libopus', '-b:a', '64k', outName); + // Parámetros compatibles con WhatsApp Voice Messages: + // -c:a libopus = códec OPUS (requerido) + // -b:a 32k = bitrate bajo para mantener archivo < 512KB (ícono play) + // -ar 48000 = sample rate 48kHz (estándar OPUS) + // -ac 1 = mono (reduce tamaño) + // -vbr on = variable bitrate para mejor calidad + await _ffmpegInstance.run('-i', inName, '-c:a', 'libopus', '-b:a', '32k', '-ar', '48000', '-ac', '1', '-vbr', 'on', outName); const outData = _ffmpegInstance.FS('readFile', outName); - const blob = new Blob([outData.buffer], { type: 'audio/ogg' }); - const newFile = new File([blob], (file.name || 'audio').replace(/\.[^/.]+$/, '') + '.ogg', { type: 'audio/ogg' }); + const blob = new Blob([outData.buffer], { type: 'audio/ogg; codecs=opus' }); + const newFile = new File([blob], (file.name || 'audio').replace(/\.[^/.]+$/, '') + '.ogg', { type: 'audio/ogg; codecs=opus' }); return newFile; } diff --git a/conversations.php b/conversations.php index 403d3e0..0ce2526 100644 --- a/conversations.php +++ b/conversations.php @@ -704,6 +704,22 @@ if (!isset($_SESSION['user_id'])) { .quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; } } + /* Indicador de nuevos mensajes */ + #new-messages-indicator { + animation: bounceIn 0.4s ease, pulse 2s ease-in-out infinite; + } + + @keyframes bounceIn { + 0% { transform: translateX(-50%) translateY(20px); opacity: 0; } + 60% { transform: translateX(-50%) translateY(-10px); opacity: 1; } + 100% { transform: translateX(-50%) translateY(-6px); opacity: 1; } + } + + @keyframes pulse { + 0%, 100% { box-shadow: 0 6px 20px rgba(37, 211, 102, 0.35); } + 50% { box-shadow: 0 6px 30px rgba(37, 211, 102, 0.55); } + } + /* Recording controls: rectangular buttons and clearer contrast */ #recording-indicator .btn { border-radius: 6px; @@ -1197,44 +1213,25 @@ if (!isset($_SESSION['user_id'])) { // Si la conversación del mensaje es la actualmente abierta if (this.currentUserId && String(this.currentUserId) === String(userId)) { - console.log('♻️ Mensaje para conversación ACTIVA, procesando...'); + console.log('♻️ Mensaje para conversación ACTIVA'); - // Verificar si el usuario está al final del chat (dentro de 150px del final) - const container = document.getElementById('chat-conversations'); - const isAtBottom = container ? - (container.scrollHeight - container.scrollTop - container.clientHeight) < 150 : true; + // SOLUCIÓN: Siempre usar staged messages + indicador + // Esto evita el error que borra la pantalla al intentar recargar automáticamente + if (!this._stagedMessages) this._stagedMessages = []; + if (!this._stagedMessageIds) this._stagedMessageIds = new Set(); - console.log('📍 Posición scroll:', { - scrollHeight: container?.scrollHeight, - scrollTop: container?.scrollTop, - clientHeight: container?.clientHeight, - distanceFromBottom: container ? (container.scrollHeight - container.scrollTop - container.clientHeight) : 0, - isAtBottom: isAtBottom - }); + // Crear key única para evitar duplicados + const msgKey = data.message_id || data.id || `${data.created_at}_${data.content?.substring(0,20)}`; - if (isAtBottom) { - // Usuario está al final: agregar mensaje automáticamente con highlight - console.log('✅ Usuario AL FINAL, recargando mensajes...'); - - // Marcar que el próximo mensaje nuevo debe tener highlight - this._nextMessageUnread = true; - - // Recargar mensajes (esto hará el fetch y renderizará) - this.loadMessages(this.currentUserId, true, true).then(() => { - // Después de cargar, quitar el highlight después de 3 segundos - setTimeout(() => { - this._removeUnreadHighlights(); - }, 3000); - }); - } else { - // Usuario está leyendo arriba: mostrar indicador de nuevos mensajes - console.log('⬆️ Usuario LEYENDO ARRIBA, mostrando indicador'); - this.showNewMessagesIndicator(1); - - // Guardar mensaje en staging para agregarlo cuando el usuario baje - if (!this._stagedMessages) this._stagedMessages = []; + if (!this._stagedMessageIds.has(msgKey)) { + this._stagedMessageIds.add(msgKey); this._stagedMessages.push(data); - console.log('💾 Mensaje guardado en staging. Total staged:', this._stagedMessages.length); + console.log('💾 Mensaje guardado en staging. Total:', this._stagedMessages.length); + + // Mostrar indicador de nuevos mensajes + this.showNewMessagesIndicator(this._stagedMessages.length); + } else { + console.log('⏭️ Mensaje duplicado, omitiendo'); } } else { console.log('📋 Mensaje para OTRA conversación, solo actualizando lista'); @@ -4063,6 +4060,8 @@ if (!isset($_SESSION['user_id'])) { // set as selected file and show preview this.selectedFile = file; + // Marcar como grabación de voz para enviar como nota de voz (ptt=true) + this._isVoiceRecording = true; this.showMediaPreview(file); // restore mic UI to default (not recording) try { @@ -4184,28 +4183,51 @@ if (!isset($_SESSION['user_id'])) { el.style.transform = 'translateX(-50%)'; el.style.bottom = '84px'; el.style.zIndex = '1500'; - el.className = 'btn btn-primary'; - el.style.padding = '8px 14px'; - el.style.borderRadius = '20px'; - el.style.boxShadow = '0 6px 18px rgba(2,6,23,0.12)'; + el.className = 'btn btn-success'; + el.style.padding = '10px 18px'; + el.style.borderRadius = '24px'; + el.style.boxShadow = '0 6px 20px rgba(37, 211, 102, 0.35)'; el.style.cursor = 'pointer'; + el.style.fontWeight = '600'; + el.style.fontSize = '14px'; + el.innerHTML = 'Nuevo mensaje'; el.onclick = async () => { try { - // Hide indicator immediately and suspend auto-refresh to avoid races - this.hideNewMessagesIndicator(); - this._suspendAutoRefresh = true; - if (this.currentUserId) { - // Trigger a full load and follow to bottom - await this.loadMessages(this.currentUserId, true, true); - } - // Clear staged messages since full load will include them + console.log('🔄 Clic en indicador de nuevos mensajes'); + + // Mostrar loading en el botón + el.innerHTML = 'Cargando...'; + el.style.pointerEvents = 'none'; + + // Limpiar staged messages this._stagedMessages = []; if (this._stagedMessageIds) this._stagedMessageIds.clear(); this._stagedCount = 0; + + // Suspender auto-refresh para evitar conflictos + this._suspendAutoRefresh = true; + + if (this.currentUserId) { + // Reset loading flag para permitir la carga + this.loadingMessages = false; + + // Cargar mensajes + await this.loadMessages(this.currentUserId, true, true); + + // Scroll al fondo + this.scrollToBottom(); + console.log('✅ Mensajes cargados correctamente'); + } + + // Ocultar indicador + this.hideNewMessagesIndicator(); + } catch (e) { - console.warn('Indicator click failed to load full conversation', e); + console.error('❌ Error al cargar mensajes:', e); + // En caso de error, intentar ocultar el indicador y restaurar estado + this.hideNewMessagesIndicator(); } finally { - try { this._suspendAutoRefresh = false; } catch(e) {} + this._suspendAutoRefresh = false; } }; // subtle fade-in @@ -4215,8 +4237,11 @@ if (!isset($_SESSION['user_id'])) { setTimeout(() => { try { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(-6px)'; } catch(e) {} }, 20); } const c = (typeof count === 'number') ? count : (this._stagedMessages ? this._stagedMessages.length : 0); - el.textContent = (c && c > 1) ? `Nuevos mensajes (${c})` : 'Nuevo mensaje'; + el.innerHTML = (c && c > 1) + ? `Nuevos mensajes (${c})` + : 'Nuevo mensaje'; el.style.display = 'inline-block'; + el.style.pointerEvents = 'auto'; // Restaurar por si estaba deshabilitado } catch (e) { console.warn('showNewMessagesIndicator failed', e); } } @@ -4257,28 +4282,37 @@ if (!isset($_SESSION['user_id'])) { // Immediately hide indicator to provide instant feedback this.hideNewMessagesIndicator(); - // Append staged messages to conversation model const toApplyCount = this._stagedMessages.length; - console.debug('applyStagedMessages: applying', toApplyCount, 'messages'); - this._stagedMessages.forEach(m => { this.currentMessages.push(m); }); - - // clear staged storage + console.log('✅ applyStagedMessages: aplicando', toApplyCount, 'mensajes'); + + // ENFOQUE SEGURO: En lugar de renderizar incrementalmente, + // simplemente recargar los mensajes desde el servidor. + // Esto es más seguro y evita problemas de renderizado. + const userId = this.currentUserId; + + // Limpiar staged messages primero this._stagedMessages = []; if (this._stagedMessageIds) this._stagedMessageIds.clear(); this._stagedCount = 0; - - // Render newly added messages and follow to bottom - this.renderMessagesIncremental(false, 0, true); - this.scrollToBottom(); - - // Update latest timestamp - try { const last = this.currentMessages[this.currentMessages.length - 1]; if (last && last.created_at) this._latestMessage = last.created_at; } catch(e){} + + // Recargar mensajes de forma segura + if (userId) { + this.loadingMessages = false; // Reset flag para permitir la carga + this.loadMessages(userId, true, true).then(() => { + console.log('✅ Mensajes recargados después de aplicar staged'); + this.scrollToBottom(); + }).catch(err => { + console.error('❌ Error recargando mensajes:', err); + // Fallback: intentar scroll al fondo de lo que ya hay + this.scrollToBottom(); + }); + } } catch (e) { console.warn('applyStagedMessages failed', e); } finally { // allow indicator to show again for future arrivals after short delay - setTimeout(() => { this._applyingStaged = false; }, 250); + setTimeout(() => { this._applyingStaged = false; }, 500); } } @@ -4717,6 +4751,9 @@ if (!isset($_SESSION['user_id'])) { const file = event.target.files[0]; if (!file) return; + // NO es grabación de voz (es archivo subido) + this._isVoiceRecording = false; + // Validar tamaño const maxSize = this.getMaxFileSize(file.type); if (file.size > maxSize) { @@ -4849,7 +4886,10 @@ if (!isset($_SESSION['user_id'])) { media_url: uploadResult.data.url, media_type: uploadResult.data.type, caption: caption || null, - filename: this.selectedFile.name + filename: this.selectedFile.name, + // Para audio: is_voice = true si fue grabado desde micrófono (nota de voz) + // is_voice = false si fue subido como archivo (audio normal) + is_voice: (uploadResult.data.type === 'audio' && this._isVoiceRecording) ? true : false }) }); @@ -4930,6 +4970,8 @@ if (!isset($_SESSION['user_id'])) { document.getElementById('media-caption').value = ''; document.getElementById('file-input').value = ''; this.selectedFile = null; + // Limpiar flag de grabación de voz + this._isVoiceRecording = false; // Restaurar botón enviar const sendBtn = document.getElementById('send-btn'); diff --git a/services/WhatsAppService.php b/services/WhatsAppService.php index 0c0a01e..78b18e1 100644 --- a/services/WhatsAppService.php +++ b/services/WhatsAppService.php @@ -337,16 +337,30 @@ class WhatsAppService /** * 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) + 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' => [ - 'link' => $audioUrl - ] + 'audio' => $audioData ]; if ($skipAutoSave) { @@ -434,8 +448,15 @@ class WhatsAppService /** * 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) + public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false) { $data = [ 'messaging_product' => 'whatsapp', @@ -453,6 +474,15 @@ class WhatsAppService $mediaData['filename'] = $filename; } + // Para audio: agregar parámetro 'ptt' (push-to-talk) si es nota de voz + // Esto hace que aparezca con la onda verde característica de WhatsApp + if ($mediaType === 'audio' && $isVoice) { + // Según documentación de Meta: "ptt": true para mensajes de voz + // El archivo debe ser .ogg con códec OPUS + $mediaData['ptt'] = true; + error_log('sendMediaById: Enviando como nota de voz (ptt=true)'); + } + $data[$mediaType] = $mediaData; // Marcar para evitar guardado automático si se solicita