Files
whatsappNucleo/app/pages/messages/index.vue
josedario87 80d0042c7e
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
Feature: Agregar botón para crear webhook de debug automáticamente
- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection
- Detectar si ya existe un webhook apuntando al receptor de debug
- Permitir eliminar el webhook de debug
- Incluir todos los eventos disponibles al crear el webhook
- También incluye mejoras previas de manejo de media y mensajes
2025-12-02 21:21:33 -06:00

435 lines
14 KiB
Vue

<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Mensajes</h1>
<p class="text-[var(--wa-text-muted)]">Vista de conversaciones de todas las instancias</p>
</div>
<div class="flex items-center gap-2">
<!-- SSE Status -->
<span
class="flex items-center gap-1 text-xs px-2 py-1 rounded"
:class="isConnected ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'"
>
<span class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'" />
{{ isConnected ? 'Realtime' : 'Offline' }}
</span>
<!-- Debug toggle -->
<button
@click="showDebugPanel = !showDebugPanel"
class="text-xs px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
>
<UIcon name="i-lucide-bug" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Debug Panel -->
<div
v-if="showDebugPanel"
class="p-4 rounded bg-gray-900 border border-gray-700 text-xs font-mono"
>
<div class="flex items-center justify-between mb-2">
<span class="text-gray-400">Last SSE Event:</span>
<div class="flex items-center gap-2">
<span class="text-gray-500">{{ lastEvent?.timestamp?.toLocaleTimeString() || 'N/A' }}</span>
<button
v-if="lastEvent"
@click="copyToClipboard(JSON.stringify(lastEvent, null, 2))"
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Copiar al portapapeles"
>
<UIcon name="i-lucide-copy" class="w-3 h-3" />
</button>
</div>
</div>
<pre v-if="lastEvent" class="text-green-400 whitespace-pre-wrap overflow-x-auto max-h-40">{{ JSON.stringify(lastEvent, null, 2) }}</pre>
<p v-else class="text-gray-500">No events received yet</p>
</div>
<!-- Instance Selector -->
<div class="flex items-center gap-4">
<USelectMenu
v-model="selectedInstance"
:items="instanceOptions"
placeholder="Seleccionar instancia"
class="w-64"
/>
</div>
<!-- Chat Interface -->
<div class="grid grid-cols-12 gap-4 h-[calc(100vh-300px)]">
<!-- Chat List -->
<div class="col-span-4 instance-card overflow-hidden flex flex-col">
<div class="p-4 border-b border-[var(--wa-border)]">
<div class="flex items-center gap-2">
<UInput
v-model="searchQuery"
placeholder="Buscar conversacion..."
icon="i-lucide-search"
class="flex-1"
/>
<button
@click="showChatsDebug = !showChatsDebug"
class="text-xs px-2 py-2 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Debug chats array"
>
<UIcon name="i-lucide-bug" class="w-4 h-4" />
</button>
</div>
<!-- Chats Debug Panel -->
<div
v-if="showChatsDebug"
class="mt-2 p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-60 overflow-auto"
>
<div class="flex items-center justify-between mb-1">
<span class="text-gray-400">Chats ({{ chats.length }}):</span>
<button
@click="copyToClipboard(JSON.stringify(chats, null, 2))"
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Copiar al portapapeles"
>
<UIcon name="i-lucide-copy" class="w-3 h-3" />
</button>
</div>
<pre class="text-green-400 whitespace-pre-wrap">{{ JSON.stringify(chats, null, 2) }}</pre>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div v-if="chats.length === 0" class="p-8 text-center">
<UIcon name="i-lucide-message-square" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
<p class="text-[var(--wa-text-muted)]">No hay conversaciones</p>
</div>
<div v-else>
<MessagesChatItem
v-for="chat in filteredChats"
:key="chat.id"
:chat="chat"
:active="selectedChat?.id === chat.id"
@click="selectedChat = chat"
/>
</div>
</div>
</div>
<!-- Message View -->
<div class="col-span-8 instance-card overflow-hidden flex flex-col">
<div v-if="!selectedChat" class="flex-1 flex items-center justify-center">
<div class="text-center">
<UIcon name="i-lucide-message-circle" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
<p class="text-[var(--wa-text-muted)]">Selecciona una conversacion</p>
</div>
</div>
<template v-else>
<!-- Chat Header -->
<div class="p-4 border-b border-[var(--wa-border)]">
<div class="flex items-center gap-3">
<UAvatar :alt="selectedChat.name" size="md" />
<div class="flex-1">
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
</div>
<button
@click="showSelectedChatDebug = !showSelectedChatDebug"
class="text-xs px-2 py-2 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Debug selected chat"
>
<UIcon name="i-lucide-bug" class="w-4 h-4" />
</button>
</div>
<!-- Selected Chat Debug Panel -->
<div
v-if="showSelectedChatDebug"
class="mt-2 p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-40 overflow-auto"
>
<div class="flex items-center justify-between mb-1">
<span class="text-gray-400">Selected Chat:</span>
<button
@click="copyToClipboard(JSON.stringify(selectedChat, null, 2))"
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Copiar al portapapeles"
>
<UIcon name="i-lucide-copy" class="w-3 h-3" />
</button>
</div>
<pre class="text-green-400 whitespace-pre-wrap">{{ JSON.stringify(selectedChat, null, 2) }}</pre>
</div>
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-2">
<MessagesMessageBubble
v-for="message in reversedMessages"
:key="message.id"
:message="message"
:instance-id="selectedInstance?.value || ''"
:is-group="selectedChat?.isGroup || false"
@reply="handleReply"
@react="handleReact"
/>
</div>
<!-- Input -->
<div class="p-4 border-t border-[var(--wa-border)]">
<MessagesMessageInput
:replying-to="replyingTo"
@send="handleSendMessage"
@send-voice="handleSendVoice"
@cancel-reply="replyingTo = null"
/>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Mensajes',
icon: 'i-lucide-message-square'
})
const { instances, fetchInstances } = useInstances()
const { isConnected, lastEvent, on } = useRealtime()
const selectedInstance = ref<{ label: string; value: string } | null>(null)
const searchQuery = ref('')
const selectedChat = ref<any>(null)
const chats = ref<any[]>([])
const messages = ref<any[]>([])
const loadingChats = ref(false)
const loadingMessages = ref(false)
const showDebugPanel = ref(false)
const showChatsDebug = ref(false)
const showSelectedChatDebug = ref(false)
// Instance options for selector
const instanceOptions = computed(() =>
instances.value
.filter(i => i.status === 'connected')
.map(i => ({ label: i.name, value: i.id }))
)
// Auto-select first connected instance
watch(instanceOptions, (opts) => {
if (opts.length > 0 && !selectedInstance.value) {
selectedInstance.value = opts[0]
}
}, { immediate: true })
// Load chats when instance changes
watch(selectedInstance, async (instance) => {
if (!instance?.value) {
chats.value = []
return
}
loadingChats.value = true
try {
chats.value = await $fetch(`/api/messages/${instance.value}/chats`)
} catch (e) {
console.error('Error loading chats:', e)
chats.value = []
} finally {
loadingChats.value = false
}
})
// Load messages when chat changes
watch(selectedChat, async (chat) => {
if (!chat || !selectedInstance.value?.value) {
messages.value = []
return
}
loadingMessages.value = true
try {
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}`)
} catch (e) {
console.error('Error loading messages:', e)
messages.value = []
} finally {
loadingMessages.value = false
}
})
const filteredChats = computed(() => {
if (!searchQuery.value) return chats.value
return chats.value.filter(chat =>
chat.name?.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
// Reverse messages for display (API returns DESC, we want ASC for chronological order)
const reversedMessages = computed(() => {
return [...messages.value].reverse()
})
const messagesContainer = ref<HTMLElement | null>(null)
// Scroll to bottom when new messages arrive
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// Watch for message changes and scroll to bottom
watch(messages, () => {
scrollToBottom()
}, { deep: true })
// Copy to clipboard function
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch (err) {
console.error('Failed to copy:', err)
}
}
// Reply state (will be used for quote functionality)
const replyingTo = ref<any>(null)
const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string) => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
const instanceId = selectedInstance.value.value
const chatId = selectedChat.value.id
// If we have files, send as media
if (files.length > 0) {
const formData = new FormData()
// Add files
for (const file of files) {
formData.append('files', file)
}
// Add caption if provided
if (caption) {
formData.append('caption', caption)
}
// Add quoted message ID if replying
if (quotedId || replyingTo.value?.messageId) {
formData.append('quotedMessageId', quotedId || replyingTo.value.messageId)
}
await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, {
method: 'POST',
body: formData
})
} else if (content) {
// Send text message
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
method: 'POST',
body: {
content,
quotedMessageId: quotedId || replyingTo.value?.messageId
}
})
}
// Clear reply state
replyingTo.value = null
// Reload messages
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
} catch (e) {
console.error('Error sending message:', e)
}
}
// Handle voice message
const handleSendVoice = async (audioFile: File) => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
const instanceId = selectedInstance.value.value
const chatId = selectedChat.value.id
const formData = new FormData()
formData.append('files', audioFile)
formData.append('isPtt', 'true') // Mark as push-to-talk (voice note)
await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, {
method: 'POST',
body: formData
})
// Reload messages
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
} catch (e) {
console.error('Error sending voice message:', e)
}
}
// Handle reply action
const handleReply = (message: any) => {
replyingTo.value = message
// TODO: Focus input and show reply preview
console.log('Reply to:', message)
}
// Handle react action
const handleReact = (message: any) => {
// TODO: Show emoji picker
console.log('React to:', message)
}
// Reload chats for current instance
const reloadChats = async () => {
if (!selectedInstance.value?.value) return
try {
chats.value = await $fetch(`/api/messages/${selectedInstance.value.value}/chats`)
} catch (e) {
console.error('Error reloading chats:', e)
}
}
// Reload messages for current chat
const reloadMessages = async () => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}`)
} catch (e) {
console.error('Error reloading messages:', e)
}
}
// Load instances on mount
onMounted(() => {
fetchInstances()
// Listen for real-time message events
on('message.received', (data) => {
console.log('[Messages] New message received:', data)
// If it's for the current instance, reload chats
if (data.instanceId === selectedInstance.value?.value) {
reloadChats()
// If it's for the current chat, reload messages
if (selectedChat.value?.jid === data.message?.key?.remoteJid) {
reloadMessages()
}
}
})
on('message.sent', (data) => {
console.log('[Messages] Message sent:', data)
// Already handled by the send function
})
})
</script>