feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s

Reemplazo completo de Evolution API por implementación directa con Baileys.

Características:
- Dashboard completo con Nuxt UI v4
- Soporte para múltiples instancias de WhatsApp
- Conexión via QR code o pairing code
- Persistencia de mensajes en PostgreSQL
- API REST para integraciones externas
- Webhooks con firma HMAC
- SSE para actualizaciones en tiempo real
- Autenticación con Authentik
This commit is contained in:
2025-12-02 17:54:31 -06:00
parent 327118440b
commit faedec47d7
62 changed files with 4489 additions and 92 deletions

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex items-center gap-3 p-3 cursor-pointer transition-colors hover:bg-[var(--wa-bg-light)]"
:class="{ 'bg-[var(--wa-bg-light)]': active }"
@click="$emit('click')"
>
<UAvatar
:alt="chat.name"
size="md"
:src="chat.profilePicture"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="font-medium text-[var(--wa-text)] truncate">{{ chat.name }}</p>
<span class="text-xs text-[var(--wa-text-muted)]">{{ formatTime(chat.lastMessageAt) }}</span>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-[var(--wa-text-muted)] truncate">{{ chat.lastMessage }}</p>
<span
v-if="chat.unreadCount > 0"
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5"
>
{{ chat.unreadCount }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Chat {
id: string
name: string
jid: string
profilePicture?: string
lastMessage: string
lastMessageAt: Date
unreadCount: number
}
interface Props {
chat: Chat
active?: boolean
}
defineProps<Props>()
defineEmits<{
click: []
}>()
const formatTime = (date: Date) => {
const d = new Date(date)
const now = new Date()
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
return d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })
} else if (days === 1) {
return 'Ayer'
} else if (days < 7) {
return d.toLocaleDateString('es-AR', { weekday: 'short' })
} else {
return d.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' })
}
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div
class="flex"
:class="message.fromMe ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[70%] rounded-lg px-3 py-2"
:class="message.fromMe ? 'bubble-out' : 'bubble-in'"
>
<!-- Message content -->
<p class="text-[var(--wa-text)] whitespace-pre-wrap break-words">{{ message.content }}</p>
<!-- Image -->
<img
v-if="message.mediaUrl && message.type === 'image'"
:src="message.mediaUrl"
class="rounded-lg max-w-full mt-2"
/>
<!-- Caption for media -->
<p
v-if="message.caption"
class="text-[var(--wa-text)] mt-2"
>
{{ message.caption }}
</p>
<!-- Footer -->
<div class="flex items-center justify-end gap-1 mt-1">
<span class="text-xs text-[var(--wa-text-muted)]">
{{ formatTime(message.timestamp) }}
</span>
<UIcon
v-if="message.fromMe"
:name="statusIcon"
class="w-4 h-4"
:class="statusColor"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Message {
id: string
content: string
type: 'text' | 'image' | 'video' | 'document' | 'audio'
mediaUrl?: string
caption?: string
fromMe: boolean
timestamp: Date
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
}
interface Props {
message: Message
}
const props = defineProps<Props>()
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('es-AR', {
hour: '2-digit',
minute: '2-digit'
})
}
const statusIcon = computed(() => {
const icons: Record<string, string> = {
pending: 'i-lucide-clock',
sent: 'i-lucide-check',
delivered: 'i-lucide-check-check',
read: 'i-lucide-check-check',
failed: 'i-lucide-alert-circle'
}
return icons[props.message.status] || 'i-lucide-check'
})
const statusColor = computed(() => {
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
if (props.message.status === 'failed') return 'text-red-500'
return 'text-[var(--wa-text-muted)]'
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex items-end gap-2">
<!-- Attachment button -->
<UButton
variant="ghost"
icon="i-lucide-paperclip"
class="text-[var(--wa-text-muted)]"
/>
<!-- Text input -->
<div class="flex-1">
<UTextarea
v-model="message"
placeholder="Escribe un mensaje..."
:rows="1"
autoresize
:maxrows="5"
class="bg-[var(--wa-bg-light)]"
@keydown.enter.exact.prevent="handleSend"
/>
</div>
<!-- Send button -->
<UButton
:icon="message.trim() ? 'i-lucide-send' : 'i-lucide-mic'"
:disabled="!message.trim()"
@click="handleSend"
/>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
send: [content: string]
}>()
const message = ref('')
const handleSend = () => {
if (!message.value.trim()) return
emit('send', message.value)
message.value = ''
}
</script>