From 7997ecb8307138f8130dfdc2ac58845d261692b5 Mon Sep 17 00:00:00 2001
From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com>
Date: Wed, 28 Jan 2026 13:05:36 -0500
Subject: [PATCH] up
---
api/send_media_message.php | 17 ++--
api/upload_media.php | 11 ++-
assets/js/chat-common.js | 12 ++-
conversations.php | 168 ++++++++++++++++++++++-------------
services/WhatsAppService.php | 40 +++++++--
5 files changed, 169 insertions(+), 79 deletions(-)
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