up
This commit is contained in:
@@ -41,3 +41,6 @@
|
|||||||
[2026-01-21 14:36:28] Request: GET /api/version/media-url.php?id=3932473397057815 GET:{"id":"3932473397057815"} POST:[]
|
[2026-01-21 14:36:28] Request: GET /api/version/media-url.php?id=3932473397057815 GET:{"id":"3932473397057815"} POST:[]
|
||||||
[2026-01-21 14:36:28] Graph API request to https://graph.facebook.com/v22.0/3932473397057815
|
[2026-01-21 14:36:28] Graph API request to https://graph.facebook.com/v22.0/3932473397057815
|
||||||
[2026-01-21 14:36:28] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=3932473397057815&source=getMedia&ext=1769024478&hash=ARnqos_pCP3NfJt-KYcvnkTwjW6kr9XG7VcoVUSjRh6YAQ
|
[2026-01-21 14:36:28] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=3932473397057815&source=getMedia&ext=1769024478&hash=ARnqos_pCP3NfJt-KYcvnkTwjW6kr9XG7VcoVUSjRh6YAQ
|
||||||
|
[2026-01-21 14:40:09] Request: GET /api/version/media-url.php?id=3932473397057815 GET:{"id":"3932473397057815"} POST:[]
|
||||||
|
[2026-01-21 14:40:09] Graph API request to https://graph.facebook.com/v22.0/3932473397057815
|
||||||
|
[2026-01-21 14:40:10] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=3932473397057815&source=getMedia&ext=1769024699&hash=ARlXiE4qXDbAzqzvCutrIryD_tdYaN1gjuIZWoBHDbilqQ
|
||||||
|
|||||||
@@ -428,6 +428,11 @@ if (empty($user_id)) {
|
|||||||
placeholder="Agregar caption (opcional)...">
|
placeholder="Agregar caption (opcional)...">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick replies (Respuestas rápidas) -->
|
||||||
|
<div id="quickRepliesContainer" style="display:none; margin-bottom:10px;">
|
||||||
|
<div class="d-flex flex-wrap" id="quickRepliesButtons" style="gap:8px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="file" id="fileInput" style="display: none;"
|
<input type="file" id="fileInput" style="display: none;"
|
||||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx" />
|
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx" />
|
||||||
@@ -444,6 +449,14 @@ if (empty($user_id)) {
|
|||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick replies toggle -->
|
||||||
|
<div class="mt-2 d-flex align-items-center">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="toggleQuickRepliesBtn" onclick="toggleQuickReplies()">
|
||||||
|
<i class="fas fa-bolt"></i> Respuestas rápidas
|
||||||
|
</button>
|
||||||
|
<small class="ms-2 text-muted">Usa respuestas automáticas rápidas</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,6 +569,9 @@ if (empty($user_id)) {
|
|||||||
// Cargar plantillas
|
// Cargar plantillas
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
|
|
||||||
|
// Cargar respuestas rápidas (quick replies)
|
||||||
|
loadQuickReplies();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showError('Error cargando datos del usuario');
|
showError('Error cargando datos del usuario');
|
||||||
}
|
}
|
||||||
@@ -986,6 +1002,54 @@ if (empty($user_id)) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Quick replies (Respuestas rápidas) =====
|
||||||
|
async function loadQuickReplies() {
|
||||||
|
try {
|
||||||
|
const resp = await whatsappManager.apiCall('get_autoresponses.php');
|
||||||
|
const container = document.getElementById('quickRepliesButtons');
|
||||||
|
const wrapper = document.getElementById('quickRepliesContainer');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (resp && resp.success && Array.isArray(resp.data)) {
|
||||||
|
const responses = resp.data;
|
||||||
|
// Filtrar solo respuestas tipo text y activas
|
||||||
|
responses.forEach(r => {
|
||||||
|
if (r.is_active && (r.response_type === 'text' || !r.response_type)) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
btn.textContent = r.response_text.length > 40 ? r.response_text.substr(0, 40) + '…' : r.response_text;
|
||||||
|
btn.title = r.response_text;
|
||||||
|
btn.onclick = function() {
|
||||||
|
// enviar la respuesta rápida
|
||||||
|
sendTextMessage(r.response_text);
|
||||||
|
// Registrar en UI
|
||||||
|
showAlert('Respuesta rápida enviada', 'success');
|
||||||
|
};
|
||||||
|
container.appendChild(btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mostrar contenedor solo si hay respuestas
|
||||||
|
if (container.children.length) {
|
||||||
|
wrapper.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando respuestas rápidas:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQuickReplies() {
|
||||||
|
const wrapper = document.getElementById('quickRepliesContainer');
|
||||||
|
if (!wrapper) return;
|
||||||
|
wrapper.style.display = (wrapper.style.display === 'block') ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
showAlert(message, 'danger');
|
showAlert(message, 'danger');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ try {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
|
|
||||||
|
<!-- Notification toasts container (global) -->
|
||||||
|
<div id="global-notification-toasts" style="position:fixed; top:12px; right:12px; z-index:9999;"></div>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="content-header">
|
<header class="content-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -77,8 +80,13 @@ try {
|
|||||||
<div class="status-badge">
|
<div class="status-badge">
|
||||||
<span class="status-dot status-online"></span>
|
<span class="status-dot status-online"></span>
|
||||||
<span class="status-text">En línea</span>
|
<span class="status-text">En línea</span>
|
||||||
</div>
|
</div> <!-- Notification bell -->
|
||||||
<button class="btn btn-primary me-2" onclick="refreshData()">
|
<div class="me-2">
|
||||||
|
<button id="notification-bell" class="btn btn-light position-relative" title="Notificaciones">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span id="notification-count" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="display:none;">0</span>
|
||||||
|
</button>
|
||||||
|
</div> <button class="btn btn-primary me-2" onclick="refreshData()">
|
||||||
<i class="fas fa-sync-alt"></i> Actualizar
|
<i class="fas fa-sync-alt"></i> Actualizar
|
||||||
</button>
|
</button>
|
||||||
<a href="logout.php" class="btn btn-outline-danger" onclick="return confirm('¿Está seguro que desea cerrar sesión?')">
|
<a href="logout.php" class="btn btn-outline-danger" onclick="return confirm('¿Está seguro que desea cerrar sesión?')">
|
||||||
@@ -924,6 +932,152 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Global Notification Manager: polls server for unread notifications and shows toasts + native notifications
|
||||||
|
const NotificationManager = {
|
||||||
|
interval: 7000,
|
||||||
|
init() {
|
||||||
|
this.bell = document.getElementById('notification-bell');
|
||||||
|
this.countEl = document.getElementById('notification-count');
|
||||||
|
this.toastsContainer = document.getElementById('global-notification-toasts');
|
||||||
|
|
||||||
|
if (this.bell) {
|
||||||
|
this.bell.addEventListener('click', () => {
|
||||||
|
// switch to conversations tab
|
||||||
|
const convLink = document.querySelector('.nav-link[data-tab="conversations"]');
|
||||||
|
if (convLink) convLink.click();
|
||||||
|
// Request notification permission on explicit interaction
|
||||||
|
if ("Notification" in window && Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission().then(p => console.log('Notification permission:', p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadNotifications();
|
||||||
|
this._timer = setInterval(() => this.loadNotifications(), this.interval);
|
||||||
|
},
|
||||||
|
async loadNotifications() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('api/get_notifications.php');
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json && json.success && Array.isArray(json.data)) {
|
||||||
|
const unreadCount = json.data.length;
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
this.countEl.textContent = unreadCount;
|
||||||
|
this.countEl.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
this.countEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
json.data.forEach(n => this.showToast(n));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading global notifications', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToast(notification) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'notification-toast';
|
||||||
|
toast.style.background = '#fff';
|
||||||
|
toast.style.padding = '10px 14px';
|
||||||
|
toast.style.border = '1px solid #ddd';
|
||||||
|
toast.style.boxShadow = '0 2px 6px rgba(0,0,0,0.12)';
|
||||||
|
toast.style.marginTop = '8px';
|
||||||
|
toast.style.borderRadius = '6px';
|
||||||
|
toast.style.minWidth = '220px';
|
||||||
|
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.textContent = notification.message || 'Nuevo evento';
|
||||||
|
toast.appendChild(msg);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.style.marginTop = '8px';
|
||||||
|
actions.style.display = 'flex';
|
||||||
|
actions.style.gap = '8px';
|
||||||
|
|
||||||
|
const openBtn = document.createElement('button');
|
||||||
|
openBtn.className = 'btn btn-sm btn-primary';
|
||||||
|
openBtn.textContent = 'Abrir';
|
||||||
|
openBtn.onclick = async () => {
|
||||||
|
await fetch('api/mark_notification_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: notification.id})});
|
||||||
|
let data = {};
|
||||||
|
try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {}
|
||||||
|
const userId = data.user_id || notification.user_id;
|
||||||
|
if (userId) {
|
||||||
|
const convLink = document.querySelector('.nav-link[data-tab="conversations"]');
|
||||||
|
if (convLink) convLink.click();
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (window.chatApp && typeof window.chatApp.openConversation === 'function') {
|
||||||
|
window.chatApp.openConversation(userId, 'Usuario', '');
|
||||||
|
} else {
|
||||||
|
setTimeout(tryOpen, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryOpen();
|
||||||
|
}
|
||||||
|
toast.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = document.createElement('button');
|
||||||
|
dismiss.className = 'btn btn-sm btn-secondary';
|
||||||
|
dismiss.textContent = 'Descartar';
|
||||||
|
dismiss.onclick = async () => {
|
||||||
|
await fetch('api/mark_notification_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: notification.id})});
|
||||||
|
toast.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.appendChild(openBtn);
|
||||||
|
actions.appendChild(dismiss);
|
||||||
|
toast.appendChild(actions);
|
||||||
|
|
||||||
|
this.toastsContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// native notification
|
||||||
|
if ("Notification" in window && Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
const data = notification.data ? JSON.parse(notification.data) : {};
|
||||||
|
const n = new Notification(notification.message || 'Nuevo evento', { body: (data.phone || '') });
|
||||||
|
n.onclick = function() {
|
||||||
|
window.focus();
|
||||||
|
const userId = data.user_id || notification.user_id;
|
||||||
|
if (userId) {
|
||||||
|
const convLink = document.querySelector('.nav-link[data-tab="conversations"]');
|
||||||
|
if (convLink) convLink.click();
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (window.chatApp && typeof window.chatApp.openConversation === 'function') {
|
||||||
|
window.chatApp.openConversation(userId, 'Usuario', '');
|
||||||
|
} else {
|
||||||
|
setTimeout(tryOpen, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryOpen();
|
||||||
|
}
|
||||||
|
n.close();
|
||||||
|
};
|
||||||
|
} catch (e) { console.warn('native notification failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// play sound
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
const g = ctx.createGain();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.value = 880;
|
||||||
|
o.connect(g);
|
||||||
|
g.connect(ctx.destination);
|
||||||
|
g.gain.value = 0.05;
|
||||||
|
o.start();
|
||||||
|
setTimeout(() => { o.stop(); ctx.close(); }, 180);
|
||||||
|
} catch (e) { console.warn('Notification sound failed', e); }
|
||||||
|
|
||||||
|
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 18000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
try { NotificationManager.init(); } catch(e) { console.error('NotificationManager init failed', e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+36
-15
@@ -36,14 +36,25 @@ class BotService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el usuario está en espera por un asesor, no procesar
|
// Si el usuario está en espera por un asesor, y el periodo no expiró, no procesar.
|
||||||
if (!empty($user['on_hold']) && $user['on_hold']) {
|
if (!empty($user['on_hold']) && $user['on_hold']) {
|
||||||
|
$now = time();
|
||||||
|
$until = null;
|
||||||
|
if (!empty($user['bot_paused_until'])) {
|
||||||
|
$until = strtotime($user['bot_paused_until']);
|
||||||
|
}
|
||||||
|
// Si hay un tiempo de expiración y aún no pasó, notificar y retornar
|
||||||
|
if ($until && $until > $now) {
|
||||||
try {
|
try {
|
||||||
$this->whatsappService->sendTextMessage($phoneNumber, "⚠️ Has sido puesto en espera por un asesor. En breve te contactarán.");
|
$this->whatsappService->sendTextMessage($phoneNumber, "⚠️ Has sido puesto en espera por un asesor. Estarás en espera hasta " . date('H:i', $until) . ". Si no hay respuesta, podrás usar *menu*." );
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
// El hold expiró, liberar y continuar
|
||||||
|
$this->releaseHold($phoneNumber);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el bot está pausado por bot_paused_until, no responder automáticamente
|
// Si el bot está pausado por bot_paused_until, no responder automáticamente
|
||||||
@@ -67,8 +78,9 @@ class BotService {
|
|||||||
if ($user['current_menu_id']) {
|
if ($user['current_menu_id']) {
|
||||||
$this->processMenuSelection($user, $messageText);
|
$this->processMenuSelection($user, $messageText);
|
||||||
} else {
|
} else {
|
||||||
// Procesar respuestas automáticas o comando menu
|
// No usar las "respuestas rápidas" (autoresponses) automáticamente desde el bot.
|
||||||
$this->processAutoResponses($phoneNumber, $messageText);
|
// Estas respuestas solo se mostrarán en la UI del chat como "respuestas rápidas".
|
||||||
|
$this->sendDefaultNoMatch($phoneNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +138,8 @@ class BotService {
|
|||||||
|
|
||||||
case 'asesor':
|
case 'asesor':
|
||||||
case 'ayuda':
|
case 'ayuda':
|
||||||
// Poner la conversación en espera y notificar al usuario
|
// Solicitar asesor: poner espera temporal de 5 minutos (no permanente)
|
||||||
$this->putOnHold($phoneNumber);
|
$this->putOnHold($phoneNumber, 5);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +338,18 @@ class BotService {
|
|||||||
$this->showMainMenu($phoneNumber);
|
$this->showMainMenu($phoneNumber);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Respuesta por defecto para mensajes no reconocidos
|
// Respuesta por defecto para mensajes no reconocidos cuando se llama explícitamente
|
||||||
|
$this->sendDefaultNoMatch($phoneNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enviar mensaje por defecto cuando no hay coincidencias
|
||||||
|
*/
|
||||||
|
private function sendDefaultNoMatch($phoneNumber) {
|
||||||
$defaultMessage = "🤖 No entendí tu mensaje. Escribe *menu* para ver las opciones disponibles o *ayuda* para obtener ayuda.";
|
$defaultMessage = "🤖 No entendí tu mensaje. Escribe *menu* para ver las opciones disponibles o *ayuda* para obtener ayuda.";
|
||||||
$this->whatsappService->sendTextMessage($phoneNumber, $defaultMessage);
|
$this->whatsappService->sendTextMessage($phoneNumber, $defaultMessage);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Salir del menú actual
|
* Salir del menú actual
|
||||||
@@ -358,13 +377,15 @@ class BotService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poner conversación en espera (asesor solicitado)
|
* Poner conversación en espera (asesor solicitado) por X minutos
|
||||||
*/
|
*/
|
||||||
public function putOnHold($phoneNumber) {
|
public function putOnHold($phoneNumber, $minutes = 5) {
|
||||||
try {
|
try {
|
||||||
$this->db->update('users', ['on_hold' => 1], 'phone_number = :phone', ['phone' => $phoneNumber]);
|
$pausedUntil = date('Y-m-d H:i:s', strtotime("+{$minutes} minutes"));
|
||||||
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Se ha solicitado un asesor. Te hemos puesto en espera. Te contactaremos en breve.");
|
// Guardar bandera y tiempo de expiración (usamos bot_paused_until que ya existe)
|
||||||
if (function_exists('writeLog')) writeLog('INFO', "Advisor requested for $phoneNumber");
|
$this->db->update('users', ['on_hold' => 1, 'bot_paused_until' => $pausedUntil], 'phone_number = :phone', ['phone' => $phoneNumber]);
|
||||||
|
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Se ha solicitado un asesor. Te hemos puesto en espera por {$minutes} minutos. Si no hay respuesta, podrás volver a usar *menu*." );
|
||||||
|
if (function_exists('writeLog')) writeLog('INFO', "Advisor requested for $phoneNumber until $pausedUntil");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('putOnHold failed: ' . $e->getMessage());
|
error_log('putOnHold failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -372,8 +393,8 @@ class BotService {
|
|||||||
|
|
||||||
public function releaseHold($phoneNumber) {
|
public function releaseHold($phoneNumber) {
|
||||||
try {
|
try {
|
||||||
$this->db->update('users', ['on_hold' => 0], 'phone_number = :phone', ['phone' => $phoneNumber]);
|
$this->db->update('users', ['on_hold' => 0, 'bot_paused_until' => null], 'phone_number = :phone', ['phone' => $phoneNumber]);
|
||||||
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Tu conversación ha sido retomada por el equipo.");
|
$this->whatsappService->sendTextMessage($phoneNumber, "🔔 Tu conversación ha sido retomada por el equipo. Puedes usar *menu* para continuar.");
|
||||||
if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber");
|
if (function_exists('writeLog')) writeLog('INFO', "Advisor released hold for $phoneNumber");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('releaseHold failed: ' . $e->getMessage());
|
error_log('releaseHold failed: ' . $e->getMessage());
|
||||||
|
|||||||
Reference in New Issue
Block a user