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

@@ -1,5 +1,18 @@
# Dominio
# WhatsApp Nucleo - Environment Variables
# Domain
APP_NAME=whatsapp-nucleo
APP_DOMAIN=whatsapp.nucleoriofrio.com
# Evolution API - Genera una key segura
EVOLUTION_API_KEY=tu-api-key-segura-aqui
# Registry (for Gitea CI/CD)
REG=git.nucleoriofrio.com
REPO_OWNER=nucleo
# PostgreSQL
POSTGRES_PASSWORD=change-this-secure-password
# Master API Key (for external API access)
MASTER_API_KEY=change-this-api-key
# Authentik (optional, for development)
NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com

View File

@@ -1,32 +1,61 @@
name: deploy
name: Build and Deploy
on:
push:
branches: [ main, master ]
jobs:
#───────────────── deploy (unified) ─────────────────
deploy:
build-and-deploy:
runs-on: docker
env:
APP_NAME: ${{ vars.APP_NAME }}
APP_DOMAIN: ${{ vars.APP_DOMAIN }}
EVOLUTION_API_KEY: ${{ secrets.EVOLUTION_API_KEY }}
REG: ${{ vars.REGISTRY_URL }}
REPO_OWNER: ${{ vars.REPO_OWNER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
steps:
- uses: actions/checkout@v3
- name: Info about environment
run: |
echo " Deploying ${{ vars.APP_NAME }}"
echo " Domain: ${{ vars.APP_DOMAIN }}"
echo " Network: principal"
echo "Building ${{ vars.APP_NAME }}"
echo "Domain: ${{ vars.APP_DOMAIN }}"
echo "Registry: ${{ vars.REGISTRY_URL }}"
- name: Pull fresh images used in compose
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:latest
${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:buildcache
cache-to: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:buildcache,mode=max
- name: Pull fresh images
run: docker compose pull
- name: Clean up stack
run: docker compose --project-name $APP_NAME down
- name: Stop existing stack
run: docker compose --project-name $APP_NAME down --remove-orphans || true
- name: Update stack
run: docker compose --project-name $APP_NAME up -d --remove-orphans --wait
- name: Start new stack
run: docker compose --project-name $APP_NAME up -d --wait
- name: Health check
run: |
echo "Waiting for application to be ready..."
sleep 15
curl -sf https://${{ vars.APP_DOMAIN }}/api/health || echo "Health check warning - may need more time to start"

27
.gitignore vendored
View File

@@ -1,2 +1,29 @@
# Nuxt dev/build outputs
.output
.nuxt
.nitro
.cache
dist
# Node
node_modules
logs
*.log
# Environment
.env
.env.*
!.env.example
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Claude
.claude/settings.local.json

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --prefer-offline --no-audit
# Copy app source
COPY . ./
# Build the application
RUN npm run build
# Production stage
FROM node:22-alpine
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app/.output /app/.output
# Expose port
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# Start the application
CMD ["node", ".output/server/index.mjs"]

8
app/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
}
}
})

20
app/app.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<UApp>
<div class="whatsapp-shell text-[var(--wa-text)]">
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</UApp>
</template>
<script setup lang="ts">
onMounted(() => {
if (import.meta.client) {
setTimeout(() => {
document.documentElement.classList.add('nuxt-ready')
}, 100)
}
})
</script>

94
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,94 @@
/* WhatsApp Nucleo - Variables de tema */
:root {
/* WhatsApp colors */
--wa-green-dark: #075e54;
--wa-green-light: #25d366;
--wa-teal: #128c7e;
--wa-blue: #34b7f1;
/* UI colors */
--wa-bg: #111b21;
--wa-bg-light: #202c33;
--wa-bg-lighter: #2a3942;
--wa-surface: #1f2c34;
--wa-border: #2a3942;
--wa-text: #e9edef;
--wa-text-muted: #8696a0;
--wa-text-dark: #667781;
/* Message bubbles */
--wa-bubble-out: #005c4b;
--wa-bubble-in: #202c33;
}
/* Shell base */
.whatsapp-shell {
background-color: var(--wa-bg);
min-height: 100vh;
}
/* Override Nuxt UI dashboard colors */
.whatsapp-shell [class*="UDashboard"] {
--ui-bg: var(--wa-bg);
}
/* Scrollbar styling */
.whatsapp-shell ::-webkit-scrollbar {
width: 6px;
}
.whatsapp-shell ::-webkit-scrollbar-track {
background: var(--wa-bg);
}
.whatsapp-shell ::-webkit-scrollbar-thumb {
background: var(--wa-border);
border-radius: 3px;
}
.whatsapp-shell ::-webkit-scrollbar-thumb:hover {
background: var(--wa-text-muted);
}
/* Status indicators */
.status-connected {
color: var(--wa-green-light);
}
.status-disconnected {
color: #f15c6d;
}
.status-connecting {
color: var(--wa-blue);
}
/* Message bubbles */
.bubble-out {
background-color: var(--wa-bubble-out);
}
.bubble-in {
background-color: var(--wa-bubble-in);
}
/* Instance card */
.instance-card {
background-color: var(--wa-surface);
border: 1px solid var(--wa-border);
border-radius: 8px;
transition: border-color 0.2s;
}
.instance-card:hover {
border-color: var(--wa-green-dark);
}
/* Loading overlay */
html:not(.nuxt-ready) body::before {
content: '';
position: fixed;
inset: 0;
background: var(--wa-bg);
z-index: 9999;
}

View File

@@ -0,0 +1,59 @@
<template>
<UDashboardSidebar class="bg-[var(--wa-surface)] border-[var(--wa-border)]">
<template #header>
<div class="flex items-center gap-3 p-4">
<div class="w-10 h-10 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
<UIcon name="i-lucide-message-circle" class="w-6 h-6 text-white" />
</div>
<div class="flex flex-col">
<span class="font-semibold text-[var(--wa-text)]">WhatsApp Nucleo</span>
<span class="text-xs text-[var(--wa-text-muted)]">Multi-Instance Manager</span>
</div>
</div>
</template>
<template #body>
<UDashboardSidebarContent>
<UDashboardSidebarGroup>
<UDashboardSidebarItem
to="/"
icon="i-lucide-layout-dashboard"
label="Dashboard"
exact
/>
<UDashboardSidebarItem
to="/instances"
icon="i-lucide-smartphone"
label="Instancias"
/>
<UDashboardSidebarItem
to="/messages"
icon="i-lucide-message-square"
label="Mensajes"
/>
</UDashboardSidebarGroup>
<UDashboardSidebarGroup label="Integraciones">
<UDashboardSidebarItem
to="/webhooks"
icon="i-lucide-webhook"
label="Webhooks"
/>
<UDashboardSidebarItem
to="/api-docs"
icon="i-lucide-code"
label="API Docs"
/>
</UDashboardSidebarGroup>
<UDashboardSidebarGroup label="Sistema">
<UDashboardSidebarItem
to="/settings"
icon="i-lucide-settings"
label="Configuracion"
/>
</UDashboardSidebarGroup>
</UDashboardSidebarContent>
</template>
</UDashboardSidebar>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--wa-surface)] border border-[var(--wa-border)]">
<span
class="w-2 h-2 rounded-full"
:class="statusClass"
/>
<span class="text-sm text-[var(--wa-text-muted)]">
{{ connectedCount }} / {{ totalCount }} conectadas
</span>
</div>
</template>
<script setup lang="ts">
// TODO: Conectar con useInstances cuando esté implementado
const connectedCount = ref(0)
const totalCount = ref(0)
const statusClass = computed(() => {
if (totalCount.value === 0) return 'bg-gray-500'
if (connectedCount.value === totalCount.value) return 'bg-[var(--wa-green-light)]'
if (connectedCount.value === 0) return 'bg-red-500'
return 'bg-yellow-500'
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<UDropdownMenu v-if="user">
<UButton
variant="ghost"
class="rounded-full p-0"
>
<UAvatar
:src="user.avatar"
:alt="user.name || user.username"
size="sm"
/>
</UButton>
<template #content>
<div class="px-3 py-2 border-b border-[var(--wa-border)]">
<p class="font-medium text-[var(--wa-text)]">{{ user.name || user.username }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ user.email }}</p>
</div>
<UDropdownMenuItem
icon="i-lucide-user"
label="Mi Perfil"
@click="goToProfile"
/>
<UDropdownMenuItem
icon="i-lucide-log-out"
label="Cerrar Sesion"
@click="logout"
/>
</template>
</UDropdownMenu>
</template>
<script setup lang="ts">
const { user, logout, goToProfile } = useAuthentik()
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div class="instance-card p-6">
<div class="flex items-center justify-between mb-4">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center"
:class="iconBgClass"
>
<UIcon :name="icon" class="w-5 h-5" :class="iconClass" />
</div>
<div v-if="total" class="text-sm text-[var(--wa-text-muted)]">
de {{ total }}
</div>
</div>
<div class="text-2xl font-bold text-[var(--wa-text)]">{{ value }}</div>
<div class="text-sm text-[var(--wa-text-muted)]">{{ title }}</div>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string
value: number | string
total?: number
icon: string
color?: 'green' | 'blue' | 'purple' | 'amber' | 'red'
}
const props = withDefaults(defineProps<Props>(), {
color: 'green'
})
const iconBgClass = computed(() => {
const classes: Record<string, string> = {
green: 'bg-green-500/20',
blue: 'bg-blue-500/20',
purple: 'bg-purple-500/20',
amber: 'bg-amber-500/20',
red: 'bg-red-500/20'
}
return classes[props.color]
})
const iconClass = computed(() => {
const classes: Record<string, string> = {
green: 'text-green-500',
blue: 'text-blue-500',
purple: 'text-purple-500',
amber: 'text-amber-500',
red: 'text-red-500'
}
return classes[props.color]
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<span
class="px-2 py-1 rounded-full text-xs font-medium"
:class="badgeClass"
>
{{ label }}
</span>
</template>
<script setup lang="ts">
interface Props {
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
}
const props = defineProps<Props>()
const statusConfig: Record<string, { label: string; class: string }> = {
connected: {
label: 'Conectado',
class: 'bg-green-500/20 text-green-400'
},
disconnected: {
label: 'Desconectado',
class: 'bg-red-500/20 text-red-400'
},
connecting: {
label: 'Conectando...',
class: 'bg-blue-500/20 text-blue-400'
},
qr_ready: {
label: 'Escanear QR',
class: 'bg-amber-500/20 text-amber-400'
},
pairing: {
label: 'Emparejando...',
class: 'bg-purple-500/20 text-purple-400'
}
}
const label = computed(() => statusConfig[props.status]?.label || props.status)
const badgeClass = computed(() => statusConfig[props.status]?.class || 'bg-gray-500/20 text-gray-400')
</script>

View File

@@ -0,0 +1,91 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Nueva Instancia</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
@click="isOpen = false"
/>
</div>
</template>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<UFormField label="Nombre de la instancia" required>
<UInput
v-model="form.name"
placeholder="Ej: Ventas, Soporte, Marketing..."
/>
</UFormField>
<div class="text-sm text-[var(--wa-text-muted)]">
<p>Despues de crear la instancia, podras conectarla escaneando un codigo QR con WhatsApp.</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<UButton
type="button"
variant="ghost"
@click="isOpen = false"
>
Cancelar
</UButton>
<UButton
type="submit"
:loading="loading"
:disabled="!form.name.trim()"
>
Crear Instancia
</UButton>
</div>
</form>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
created: [instance: any]
}>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const loading = ref(false)
const form = ref({
name: ''
})
const handleSubmit = async () => {
if (!form.value.name.trim()) return
loading.value = true
try {
const instance = await $fetch('/api/instances', {
method: 'POST',
body: { name: form.value.name }
})
emit('created', instance)
form.value.name = ''
} catch (error) {
console.error('Error creating instance:', error)
// TODO: Show error toast
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,117 @@
<template>
<div class="instance-card p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
<UIcon name="i-lucide-smartphone" class="w-6 h-6 text-white" />
</div>
<div>
<h3 class="font-semibold text-[var(--wa-text)]">{{ instance.name }}</h3>
<p class="text-sm text-[var(--wa-text-muted)]">
{{ instance.phoneNumber || 'Sin numero asignado' }}
</p>
</div>
</div>
<StatusBadge :status="instance.status" />
</div>
<!-- Connection info -->
<div v-if="instance.lastConnectedAt" class="text-xs text-[var(--wa-text-muted)] mb-4">
Ultima conexion: {{ formatDate(instance.lastConnectedAt) }}
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<template v-if="instance.status === 'disconnected'">
<UButton
size="sm"
icon="i-lucide-qr-code"
@click="$emit('connect', instance.id)"
>
Conectar
</UButton>
</template>
<template v-else-if="instance.status === 'connected'">
<UButton
size="sm"
variant="soft"
icon="i-lucide-message-square"
:to="`/messages?instance=${instance.id}`"
>
Mensajes
</UButton>
<UButton
size="sm"
variant="ghost"
color="red"
icon="i-lucide-unplug"
@click="$emit('disconnect', instance.id)"
>
Desconectar
</UButton>
</template>
<template v-else-if="instance.status === 'qr_ready'">
<UButton
size="sm"
icon="i-lucide-qr-code"
@click="$emit('connect', instance.id)"
>
Ver QR
</UButton>
</template>
<template v-else>
<UButton
size="sm"
variant="ghost"
loading
>
Procesando...
</UButton>
</template>
<!-- Delete button -->
<UButton
size="sm"
variant="ghost"
color="red"
icon="i-lucide-trash-2"
class="ml-auto"
@click="confirmDelete"
/>
</div>
</div>
</template>
<script setup lang="ts">
interface Instance {
id: string
name: string
phoneNumber: string | null
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
lastConnectedAt: Date | null
}
interface Props {
instance: Instance
}
defineProps<Props>()
const emit = defineEmits<{
connect: [instanceId: string]
disconnect: [instanceId: string]
delete: [instanceId: string]
}>()
const formatDate = (date: Date) => {
return new Date(date).toLocaleString('es-AR')
}
const confirmDelete = () => {
// TODO: Show confirmation modal
emit('delete', props.instance.id)
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-md' }">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Escanear Codigo QR</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
@click="isOpen = false"
/>
</div>
</template>
<div class="text-center">
<!-- QR Code Display -->
<div v-if="qrCode" class="mb-4">
<div class="bg-white p-4 rounded-lg inline-block">
<img :src="qrCode" alt="QR Code" class="w-64 h-64" />
</div>
</div>
<div v-else class="mb-4 py-16">
<UIcon
name="i-lucide-loader-2"
class="w-12 h-12 text-[var(--wa-text-muted)] animate-spin mx-auto"
/>
<p class="text-[var(--wa-text-muted)] mt-4">Generando codigo QR...</p>
</div>
<!-- Instructions -->
<div class="text-sm text-[var(--wa-text-muted)] space-y-2">
<p>1. Abre WhatsApp en tu telefono</p>
<p>2. Ve a Configuracion > Dispositivos vinculados</p>
<p>3. Toca "Vincular un dispositivo"</p>
<p>4. Escanea este codigo QR</p>
</div>
<!-- Alternative: Pairing Code -->
<div class="mt-6 pt-6 border-t border-[var(--wa-border)]">
<p class="text-sm text-[var(--wa-text-muted)] mb-3">
O usa un codigo de emparejamiento
</p>
<UButton
variant="soft"
@click="requestPairingCode"
:loading="loadingPairing"
>
Obtener Codigo
</UButton>
<div v-if="pairingCode" class="mt-4">
<p class="text-2xl font-mono font-bold text-[var(--wa-green-light)] tracking-widest">
{{ pairingCode }}
</p>
<p class="text-xs text-[var(--wa-text-muted)] mt-2">
Ingresa este codigo en WhatsApp > Dispositivos vinculados > Vincular con numero de telefono
</p>
</div>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface Props {
open: boolean
instanceId: string | null
qrCode: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const loadingPairing = ref(false)
const pairingCode = ref<string | null>(null)
const requestPairingCode = async () => {
if (!props.instanceId) return
loadingPairing.value = true
try {
const response = await $fetch<{ code: string }>(`/api/instances/${props.instanceId}/pairing-code`, {
method: 'POST'
})
pairingCode.value = response.code
} catch (error) {
console.error('Error requesting pairing code:', error)
} finally {
loadingPairing.value = false
}
}
// Reset pairing code when modal closes
watch(isOpen, (value) => {
if (!value) {
pairingCode.value = null
}
})
</script>

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>

View File

@@ -0,0 +1,107 @@
<template>
<div class="instance-card p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center"
:class="webhook.isActive ? 'bg-green-500/20' : 'bg-gray-500/20'"
>
<UIcon
name="i-lucide-webhook"
class="w-5 h-5"
:class="webhook.isActive ? 'text-green-500' : 'text-gray-500'"
/>
</div>
<div>
<h3 class="font-semibold text-[var(--wa-text)]">{{ webhook.name }}</h3>
<p class="text-sm text-[var(--wa-text-muted)] truncate max-w-md">{{ webhook.url }}</p>
</div>
</div>
<USwitch
:model-value="webhook.isActive"
@update:model-value="$emit('toggle', webhook.id, $event)"
/>
</div>
<!-- Events -->
<div class="mb-4">
<p class="text-xs text-[var(--wa-text-muted)] mb-2">Eventos:</p>
<div class="flex flex-wrap gap-1">
<span
v-for="event in webhook.events"
:key="event"
class="bg-[var(--wa-bg-light)] text-[var(--wa-text-muted)] text-xs px-2 py-1 rounded"
>
{{ event }}
</span>
</div>
</div>
<!-- Instance -->
<div v-if="webhook.instanceId" class="mb-4">
<p class="text-xs text-[var(--wa-text-muted)]">
Instancia: <span class="text-[var(--wa-text)]">{{ webhook.instanceName || webhook.instanceId }}</span>
</p>
</div>
<div v-else class="mb-4">
<p class="text-xs text-[var(--wa-text-muted)]">
Aplica a: <span class="text-[var(--wa-green-light)]">Todas las instancias</span>
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<UButton
size="sm"
variant="soft"
icon="i-lucide-play"
@click="$emit('test', webhook.id)"
>
Probar
</UButton>
<UButton
size="sm"
variant="ghost"
icon="i-lucide-pencil"
@click="$emit('edit', webhook)"
>
Editar
</UButton>
<UButton
size="sm"
variant="ghost"
color="red"
icon="i-lucide-trash-2"
@click="$emit('delete', webhook.id)"
>
Eliminar
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
interface Webhook {
id: string
name: string
url: string
events: string[]
isActive: boolean
instanceId?: string
instanceName?: string
}
interface Props {
webhook: Webhook
}
defineProps<Props>()
defineEmits<{
edit: [webhook: Webhook]
delete: [webhookId: string]
test: [webhookId: string]
toggle: [webhookId: string, active: boolean]
}>()
</script>

View File

@@ -0,0 +1,182 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-[var(--wa-text)]">
{{ webhook ? 'Editar Webhook' : 'Nuevo Webhook' }}
</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
@click="isOpen = false"
/>
</div>
</template>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<UFormField label="Nombre" required>
<UInput
v-model="form.name"
placeholder="Ej: Notificaciones a n8n"
/>
</UFormField>
<UFormField label="URL" required>
<UInput
v-model="form.url"
placeholder="https://..."
type="url"
/>
</UFormField>
<UFormField label="Secret (opcional)">
<UInput
v-model="form.secret"
placeholder="Para firmar las peticiones con HMAC"
type="password"
/>
</UFormField>
<UFormField label="Eventos">
<div class="grid grid-cols-2 gap-2">
<UCheckbox
v-for="event in availableEvents"
:key="event.value"
v-model="form.events"
:value="event.value"
:label="event.label"
/>
</div>
</UFormField>
<UFormField label="Instancia">
<USelectMenu
v-model="form.instanceId"
:items="instanceOptions"
placeholder="Todas las instancias"
/>
</UFormField>
</div>
<div class="flex justify-end gap-3 mt-6">
<UButton
type="button"
variant="ghost"
@click="isOpen = false"
>
Cancelar
</UButton>
<UButton
type="submit"
:loading="loading"
:disabled="!isValid"
>
{{ webhook ? 'Guardar Cambios' : 'Crear Webhook' }}
</UButton>
</div>
</form>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface Webhook {
id: string
name: string
url: string
secret?: string
events: string[]
instanceId?: string
}
interface Props {
open: boolean
webhook?: Webhook | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: [webhook: any]
}>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const loading = ref(false)
const form = ref({
name: '',
url: '',
secret: '',
events: [] as string[],
instanceId: null as string | null
})
const availableEvents = [
{ value: 'message.received', label: 'Mensaje recibido' },
{ value: 'message.sent', label: 'Mensaje enviado' },
{ value: 'message.status', label: 'Estado de mensaje' },
{ value: 'instance.connected', label: 'Instancia conectada' },
{ value: 'instance.disconnected', label: 'Instancia desconectada' },
{ value: 'instance.qr', label: 'QR disponible' }
]
// TODO: Cargar instancias reales
const instanceOptions = ref<any[]>([])
const isValid = computed(() => {
return form.value.name.trim() && form.value.url.trim() && form.value.events.length > 0
})
// Watch for webhook changes to populate form
watch(() => props.webhook, (webhook) => {
if (webhook) {
form.value = {
name: webhook.name,
url: webhook.url,
secret: webhook.secret || '',
events: [...webhook.events],
instanceId: webhook.instanceId || null
}
} else {
form.value = {
name: '',
url: '',
secret: '',
events: [],
instanceId: null
}
}
}, { immediate: true })
const handleSubmit = async () => {
if (!isValid.value) return
loading.value = true
try {
const method = props.webhook ? 'PUT' : 'POST'
const url = props.webhook
? `/api/webhooks/${props.webhook.id}`
: '/api/webhooks'
const result = await $fetch(url, {
method,
body: form.value
})
emit('saved', result)
} catch (error) {
console.error('Error saving webhook:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,68 @@
/**
* Composable para leer información de usuario de Authentik
* Los headers son inyectados por Authentik Proxy Outpost
*/
interface AuthentikUser {
username: string
email: string | undefined
name: string | undefined
groups: string[]
uid: string | undefined
avatar: string
}
export const useAuthentik = () => {
const authentikUser = useState<AuthentikUser | null>('authentikUser', () => {
if (import.meta.server) {
const headers = useRequestHeaders()
const username = headers['x-authentik-username']
const email = headers['x-authentik-email']
const name = headers['x-authentik-name']
const groups = headers['x-authentik-groups']
const uid = headers['x-authentik-uid']
if (!username) {
return null
}
return {
username,
email,
name,
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
uid,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=075e54&color=fff&size=128`
}
}
return null
})
const user = computed(() => authentikUser.value)
const isAuthenticated = computed(() => !!user.value)
const logout = () => {
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/flows/-/default/invalidation/`, { external: true })
}
const goToProfile = () => {
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/if/user/`, { external: true, open: { target: '_blank' } })
}
const hasGroup = (groupName: string): boolean => {
if (!user.value) return false
return user.value.groups.includes(groupName)
}
return {
user,
isAuthenticated,
logout,
goToProfile,
hasGroup
}
}

View File

@@ -0,0 +1,124 @@
/**
* Composable for managing WhatsApp instances
*/
export interface Instance {
id: string
name: string
phoneNumber: string | null
status: 'disconnected' | 'connecting' | 'connected' | 'qr_ready' | 'pairing'
qrCode: string | null
pairingCode: string | null
lastConnectedAt: Date | null
createdAt: Date
}
export const useInstances = () => {
const instances = useState<Instance[]>('instances', () => [])
const loading = useState('instancesLoading', () => false)
const error = useState<string | null>('instancesError', () => null)
const fetchInstances = async () => {
loading.value = true
error.value = null
try {
const data = await $fetch<Instance[]>('/api/instances')
instances.value = data
} catch (e) {
error.value = (e as Error).message
console.error('Error fetching instances:', e)
} finally {
loading.value = false
}
}
const createInstance = async (name: string): Promise<Instance | null> => {
try {
const instance = await $fetch<Instance>('/api/instances', {
method: 'POST',
body: { name }
})
instances.value.push(instance)
return instance
} catch (e) {
console.error('Error creating instance:', e)
throw e
}
}
const deleteInstance = async (id: string) => {
try {
await $fetch(`/api/instances/${id}`, { method: 'DELETE' })
instances.value = instances.value.filter(i => i.id !== id)
} catch (e) {
console.error('Error deleting instance:', e)
throw e
}
}
const connectInstance = async (id: string): Promise<{ qrCode: string | null; status: string }> => {
const result = await $fetch<{ qrCode: string | null; status: string }>(`/api/instances/${id}/connect`, {
method: 'POST'
})
// Update local state
const idx = instances.value.findIndex(i => i.id === id)
if (idx !== -1) {
instances.value[idx].status = result.status as Instance['status']
instances.value[idx].qrCode = result.qrCode
}
return result
}
const disconnectInstance = async (id: string) => {
await $fetch(`/api/instances/${id}/disconnect`, { method: 'POST' })
// Update local state
const idx = instances.value.findIndex(i => i.id === id)
if (idx !== -1) {
instances.value[idx].status = 'disconnected'
instances.value[idx].qrCode = null
}
}
const getQRCode = async (id: string): Promise<string | null> => {
const result = await $fetch<{ qrCode: string | null }>(`/api/instances/${id}/qr`)
return result.qrCode
}
const requestPairingCode = async (id: string, phoneNumber: string): Promise<string> => {
const result = await $fetch<{ code: string }>(`/api/instances/${id}/pairing-code`, {
method: 'POST',
body: { phoneNumber }
})
return result.code
}
const getInstanceStatus = async (id: string) => {
return await $fetch(`/api/instances/${id}/status`)
}
// Computed helpers
const connectedCount = computed(() =>
instances.value.filter(i => i.status === 'connected').length
)
const totalCount = computed(() => instances.value.length)
return {
instances,
loading,
error,
fetchInstances,
createInstance,
deleteInstance,
connectInstance,
disconnectInstance,
getQRCode,
requestPairingCode,
getInstanceStatus,
connectedCount,
totalCount
}
}

View File

@@ -0,0 +1,22 @@
/**
* Composable para gestionar el estado del sidebar
*/
export const useSidebarState = () => {
const isOpen = useState('sidebarOpen', () => true)
const isCollapsed = useState('sidebarCollapsed', () => false)
const toggle = () => {
isOpen.value = !isOpen.value
}
const collapse = () => {
isCollapsed.value = !isCollapsed.value
}
return {
isOpen,
isCollapsed,
toggle,
collapse
}
}

View File

@@ -0,0 +1,4 @@
/**
* Re-export useToast from Nuxt UI
*/
export { useToast } from '#imports'

39
app/layouts/dashboard.vue Normal file
View File

@@ -0,0 +1,39 @@
<template>
<div class="whatsapp-shell min-h-screen text-[var(--wa-text)]">
<UDashboardGroup storage-key="whatsapp-dashboard" class="h-full">
<AppSidebar />
<UDashboardPanel class="bg-transparent">
<template #header>
<div class="flex flex-col gap-4 px-4 py-4 lg:px-6">
<UDashboardNavbar :title="pageTitle" :icon="pageIcon" toggle-side="left">
<template #leading>
<UDashboardSidebarCollapse variant="subtle" />
</template>
<template #toggle>
<UDashboardSidebarToggle variant="subtle" />
</template>
<template #trailing>
<ConnectionStatus />
<UserMenu />
</template>
</UDashboardNavbar>
</div>
</template>
<template #body>
<div class="px-4 pb-10 lg:px-8">
<slot />
</div>
</template>
</UDashboardPanel>
</UDashboardGroup>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const pageTitle = computed(() => (route.meta.title as string) || 'WhatsApp Nucleo')
const pageIcon = computed(() => (route.meta.icon as string) || 'i-lucide-message-circle')
</script>

103
app/pages/api-docs.vue Normal file
View File

@@ -0,0 +1,103 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Documentacion de API</h1>
<p class="text-[var(--wa-text-muted)]">Guia para integrar WhatsApp Nucleo con tus sistemas</p>
</div>
<!-- Authentication -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Autenticacion</h2>
<p class="text-[var(--wa-text-muted)] mb-4">
Todas las llamadas a la API externa requieren un API Key en el header <code class="bg-[var(--wa-bg-light)] px-2 py-1 rounded">Authorization</code>.
</p>
<div class="bg-[var(--wa-bg)] p-4 rounded-lg font-mono text-sm">
<span class="text-[var(--wa-text-muted)]"># Ejemplo de autenticacion</span><br>
<span class="text-[var(--wa-blue)]">curl</span> -X POST https://whatsapp.nucleoriofrio.com/api/messages/send \<br>
&nbsp;&nbsp;-H <span class="text-[var(--wa-green-light)]">"Authorization: Bearer YOUR_API_KEY"</span> \<br>
&nbsp;&nbsp;-H <span class="text-[var(--wa-green-light)]">"Content-Type: application/json"</span> \<br>
&nbsp;&nbsp;-d '{"instanceId": "...", "to": "...", "message": "..."}'
</div>
</div>
<!-- Endpoints -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Endpoints Disponibles</h2>
<div class="space-y-4">
<!-- Send Message -->
<div class="border border-[var(--wa-border)] rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="bg-green-600 text-white text-xs px-2 py-1 rounded font-semibold">POST</span>
<code class="text-[var(--wa-text)]">/api/messages/send</code>
</div>
<p class="text-[var(--wa-text-muted)] text-sm mb-3">Envia un mensaje de texto a un numero de WhatsApp</p>
<div class="bg-[var(--wa-bg)] p-3 rounded text-sm font-mono">
<span class="text-[var(--wa-text-muted)]">// Request body</span><br>
{<br>
&nbsp;&nbsp;"instanceId": <span class="text-[var(--wa-green-light)]">"uuid-de-instancia"</span>,<br>
&nbsp;&nbsp;"to": <span class="text-[var(--wa-green-light)]">"5491123456789"</span>,<br>
&nbsp;&nbsp;"message": <span class="text-[var(--wa-green-light)]">"Hola desde la API!"</span><br>
}
</div>
</div>
<!-- List Instances -->
<div class="border border-[var(--wa-border)] rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="bg-blue-600 text-white text-xs px-2 py-1 rounded font-semibold">GET</span>
<code class="text-[var(--wa-text)]">/api/instances</code>
</div>
<p class="text-[var(--wa-text-muted)] text-sm">Obtiene la lista de instancias disponibles</p>
</div>
<!-- Get Instance Status -->
<div class="border border-[var(--wa-border)] rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="bg-blue-600 text-white text-xs px-2 py-1 rounded font-semibold">GET</span>
<code class="text-[var(--wa-text)]">/api/instances/:id/status</code>
</div>
<p class="text-[var(--wa-text-muted)] text-sm">Obtiene el estado de conexion de una instancia</p>
</div>
</div>
</div>
<!-- Webhook Events -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Eventos de Webhook</h2>
<p class="text-[var(--wa-text-muted)] mb-4">
Los webhooks envian notificaciones cuando ocurren estos eventos:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
<code class="text-[var(--wa-green-light)]">message.received</code>
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Se recibio un mensaje</p>
</div>
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
<code class="text-[var(--wa-green-light)]">message.sent</code>
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Se envio un mensaje</p>
</div>
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
<code class="text-[var(--wa-green-light)]">instance.connected</code>
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Instancia conectada</p>
</div>
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
<code class="text-[var(--wa-green-light)]">instance.disconnected</code>
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Instancia desconectada</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'API Docs',
icon: 'i-lucide-code'
})
</script>

113
app/pages/index.vue Normal file
View File

@@ -0,0 +1,113 @@
<template>
<div class="space-y-6">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Instancias Activas"
:value="stats.connectedInstances"
:total="stats.totalInstances"
icon="i-lucide-smartphone"
color="green"
/>
<MetricCard
title="Mensajes Hoy"
:value="stats.messagesToday"
icon="i-lucide-message-square"
color="blue"
/>
<MetricCard
title="Webhooks Activos"
:value="stats.activeWebhooks"
icon="i-lucide-webhook"
color="purple"
/>
<MetricCard
title="Chats Activos"
:value="stats.activeChats"
icon="i-lucide-users"
color="amber"
/>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Instances Overview -->
<div class="instance-card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Instancias</h3>
<UButton
to="/instances"
variant="ghost"
size="sm"
trailing-icon="i-lucide-arrow-right"
>
Ver todas
</UButton>
</div>
<div v-if="instances.length === 0" class="text-center py-8">
<UIcon name="i-lucide-smartphone" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
<p class="text-[var(--wa-text-muted)]">No hay instancias configuradas</p>
<UButton
to="/instances"
variant="soft"
class="mt-4"
>
Crear primera instancia
</UButton>
</div>
<div v-else class="space-y-3">
<div
v-for="instance in instances.slice(0, 3)"
:key="instance.id"
class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]"
>
<div class="flex items-center gap-3">
<span
class="w-3 h-3 rounded-full"
:class="instance.status === 'connected' ? 'bg-[var(--wa-green-light)]' : 'bg-red-500'"
/>
<div>
<p class="font-medium text-[var(--wa-text)]">{{ instance.name }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ instance.phoneNumber || 'Sin conectar' }}</p>
</div>
</div>
<StatusBadge :status="instance.status" />
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="instance-card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Actividad Reciente</h3>
</div>
<div class="text-center py-8">
<UIcon name="i-lucide-activity" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
<p class="text-[var(--wa-text-muted)]">La actividad aparecera aqui</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Dashboard',
icon: 'i-lucide-layout-dashboard'
})
// TODO: Conectar con datos reales
const stats = ref({
totalInstances: 0,
connectedInstances: 0,
messagesToday: 0,
activeWebhooks: 0,
activeChats: 0
})
const instances = ref<any[]>([])
</script>

View File

@@ -0,0 +1,91 @@
<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)]">Instancias de WhatsApp</h1>
<p class="text-[var(--wa-text-muted)]">Gestiona tus conexiones de WhatsApp</p>
</div>
<UButton
icon="i-lucide-plus"
@click="showCreateModal = true"
>
Nueva Instancia
</UButton>
</div>
<!-- Instances Grid -->
<div v-if="instances.length === 0" class="instance-card p-12 text-center">
<UIcon name="i-lucide-smartphone" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
<h3 class="text-xl font-semibold text-[var(--wa-text)] mb-2">No hay instancias</h3>
<p class="text-[var(--wa-text-muted)] mb-6">Crea tu primera instancia para comenzar a usar WhatsApp Nucleo</p>
<UButton
icon="i-lucide-plus"
@click="showCreateModal = true"
>
Crear Instancia
</UButton>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<InstanceCard
v-for="instance in instances"
:key="instance.id"
:instance="instance"
@connect="handleConnect"
@disconnect="handleDisconnect"
@delete="handleDelete"
/>
</div>
<!-- Create Instance Modal -->
<CreateInstanceModal
v-model:open="showCreateModal"
@created="handleCreated"
/>
<!-- QR Code Modal -->
<QRCodeModal
v-model:open="showQRModal"
:instance-id="selectedInstanceId"
:qr-code="qrCode"
/>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Instancias',
icon: 'i-lucide-smartphone'
})
const showCreateModal = ref(false)
const showQRModal = ref(false)
const selectedInstanceId = ref<string | null>(null)
const qrCode = ref<string | null>(null)
// TODO: Conectar con API real
const instances = ref<any[]>([])
const handleConnect = async (instanceId: string) => {
selectedInstanceId.value = instanceId
showQRModal.value = true
// TODO: Iniciar conexion y obtener QR
}
const handleDisconnect = async (instanceId: string) => {
// TODO: Desconectar instancia
console.log('Disconnecting', instanceId)
}
const handleDelete = async (instanceId: string) => {
// TODO: Eliminar instancia
console.log('Deleting', instanceId)
}
const handleCreated = (instance: any) => {
instances.value.push(instance)
showCreateModal.value = false
}
</script>

View File

@@ -0,0 +1,116 @@
<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>
<!-- 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)]">
<UInput
v-model="searchQuery"
placeholder="Buscar conversacion..."
icon="i-lucide-search"
/>
</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>
<ChatItem
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)] flex items-center gap-3">
<UAvatar :alt="selectedChat.name" size="md" />
<div>
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-2">
<MessageBubble
v-for="message in messages"
:key="message.id"
:message="message"
/>
</div>
<!-- Input -->
<div class="p-4 border-t border-[var(--wa-border)]">
<MessageInput @send="handleSendMessage" />
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Mensajes',
icon: 'i-lucide-message-square'
})
const selectedInstance = ref(null)
const searchQuery = ref('')
const selectedChat = ref<any>(null)
// TODO: Conectar con API real
const instanceOptions = ref<any[]>([])
const chats = ref<any[]>([])
const messages = ref<any[]>([])
const filteredChats = computed(() => {
if (!searchQuery.value) return chats.value
return chats.value.filter(chat =>
chat.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const handleSendMessage = async (content: string) => {
console.log('Sending:', content)
// TODO: Implementar envio de mensajes
}
</script>

138
app/pages/settings.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Configuracion</h1>
<p class="text-[var(--wa-text-muted)]">Ajustes del sistema WhatsApp Nucleo</p>
</div>
<!-- API Keys -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">API Keys</h2>
<p class="text-[var(--wa-text-muted)] mb-4">
Gestiona las claves de acceso para la API externa
</p>
<div v-if="apiKeys.length === 0" class="text-center py-8">
<p class="text-[var(--wa-text-muted)]">No hay API Keys configuradas</p>
</div>
<div v-else class="space-y-3 mb-4">
<div
v-for="key in apiKeys"
:key="key.id"
class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]"
>
<div>
<p class="font-medium text-[var(--wa-text)]">{{ key.name }}</p>
<p class="text-sm text-[var(--wa-text-muted)]">{{ key.keyPrefix }}... | Creada: {{ key.createdAt }}</p>
</div>
<UButton
variant="ghost"
color="red"
icon="i-lucide-trash-2"
@click="deleteApiKey(key.id)"
/>
</div>
</div>
<UButton
icon="i-lucide-plus"
variant="soft"
@click="showCreateKeyModal = true"
>
Crear API Key
</UButton>
</div>
<!-- General Settings -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Configuracion General</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-[var(--wa-text)]">Limite de instancias</p>
<p class="text-sm text-[var(--wa-text-muted)]">Maximo numero de instancias permitidas</p>
</div>
<UInput
v-model.number="settings.maxInstances"
type="number"
class="w-24"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-[var(--wa-text)]">Timeout de Webhooks</p>
<p class="text-sm text-[var(--wa-text-muted)]">Tiempo maximo de espera para webhooks (ms)</p>
</div>
<UInput
v-model.number="settings.webhookTimeout"
type="number"
class="w-24"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-[var(--wa-text)]">Reintentos de Webhook</p>
<p class="text-sm text-[var(--wa-text-muted)]">Intentos de reenvio si falla</p>
</div>
<UInput
v-model.number="settings.webhookRetries"
type="number"
class="w-24"
/>
</div>
</div>
</div>
<!-- System Info -->
<div class="instance-card p-6">
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Informacion del Sistema</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-[var(--wa-text-muted)]">Version</p>
<p class="text-[var(--wa-text)]">1.0.0</p>
</div>
<div>
<p class="text-[var(--wa-text-muted)]">Baileys</p>
<p class="text-[var(--wa-text)]">v7.0.0-rc.9</p>
</div>
<div>
<p class="text-[var(--wa-text-muted)]">Node.js</p>
<p class="text-[var(--wa-text)]">v22.x</p>
</div>
<div>
<p class="text-[var(--wa-text-muted)]">PostgreSQL</p>
<p class="text-[var(--wa-text)]">v16</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Configuracion',
icon: 'i-lucide-settings'
})
const showCreateKeyModal = ref(false)
// TODO: Conectar con API real
const apiKeys = ref<any[]>([])
const settings = ref({
maxInstances: 10,
webhookTimeout: 5000,
webhookRetries: 3
})
const deleteApiKey = async (keyId: string) => {
console.log('Deleting API key', keyId)
}
</script>

View File

@@ -0,0 +1,86 @@
<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)]">Webhooks</h1>
<p class="text-[var(--wa-text-muted)]">Configura notificaciones para eventos de WhatsApp</p>
</div>
<UButton
icon="i-lucide-plus"
@click="showCreateModal = true"
>
Nuevo Webhook
</UButton>
</div>
<!-- Webhooks List -->
<div v-if="webhooks.length === 0" class="instance-card p-12 text-center">
<UIcon name="i-lucide-webhook" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
<h3 class="text-xl font-semibold text-[var(--wa-text)] mb-2">No hay webhooks configurados</h3>
<p class="text-[var(--wa-text-muted)] mb-6">Los webhooks te permiten recibir notificaciones en tiempo real cuando ocurren eventos en WhatsApp</p>
<UButton
icon="i-lucide-plus"
@click="showCreateModal = true"
>
Crear Webhook
</UButton>
</div>
<div v-else class="space-y-4">
<WebhookCard
v-for="webhook in webhooks"
:key="webhook.id"
:webhook="webhook"
@edit="handleEdit"
@delete="handleDelete"
@test="handleTest"
@toggle="handleToggle"
/>
</div>
<!-- Create Webhook Modal -->
<WebhookFormModal
v-model:open="showCreateModal"
:webhook="editingWebhook"
@saved="handleSaved"
/>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
title: 'Webhooks',
icon: 'i-lucide-webhook'
})
const showCreateModal = ref(false)
const editingWebhook = ref<any>(null)
// TODO: Conectar con API real
const webhooks = ref<any[]>([])
const handleEdit = (webhook: any) => {
editingWebhook.value = webhook
showCreateModal.value = true
}
const handleDelete = async (webhookId: string) => {
console.log('Deleting webhook', webhookId)
}
const handleTest = async (webhookId: string) => {
console.log('Testing webhook', webhookId)
}
const handleToggle = async (webhookId: string, active: boolean) => {
console.log('Toggle webhook', webhookId, active)
}
const handleSaved = (webhook: any) => {
showCreateModal.value = false
editingWebhook.value = null
// TODO: Refresh list
}
</script>

View File

@@ -1,96 +1,75 @@
version: '3.8'
services:
evolution-postgres:
image: postgres:15-alpine
container_name: evolution-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=evolution
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=evolution
volumes:
- evolution_postgres:/var/lib/postgresql/data
networks:
- principal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U evolution"]
interval: 5s
timeout: 5s
retries: 5
evolution-api:
image: atendai/evolution-api:latest
container_name: evolution-api
whatsapp-nucleo:
build: .
image: ${REG}/${REPO_OWNER}/${APP_NAME}:latest
container_name: ${APP_NAME}
restart: unless-stopped
depends_on:
evolution-postgres:
whatsapp-postgres:
condition: service_healthy
environment:
# Configuración básica
- SERVER_URL=https://${APP_DOMAIN}
- AUTHENTICATION_TYPE=apikey
- AUTHENTICATION_API_KEY=${EVOLUTION_API_KEY}
- AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES=true
# Base de datos PostgreSQL
- DATABASE_ENABLED=true
- DATABASE_PROVIDER=postgresql
- DATABASE_CONNECTION_URI=postgresql://evolution:${POSTGRES_PASSWORD}@evolution-postgres:5432/evolution
- DATABASE_SAVE_DATA_INSTANCE=true
- DATABASE_SAVE_DATA_NEW_MESSAGE=true
- DATABASE_SAVE_MESSAGE_UPDATE=true
- DATABASE_SAVE_DATA_CONTACTS=true
- DATABASE_SAVE_DATA_CHATS=true
- DATABASE_SAVE_DATA_LABELS=true
- DATABASE_SAVE_DATA_HISTORIC=true
# Redis deshabilitado
- CACHE_REDIS_ENABLED=false
# Webhooks globales (deshabilitado por ahora)
- WEBHOOK_GLOBAL_ENABLED=false
# Integraciones (deshabilitadas)
- CHATWOOT_ENABLED=false
- TYPEBOT_ENABLED=false
volumes:
- evolution_instances:/evolution/instances
- evolution_store:/evolution/store
- NODE_ENV=production
- NUXT_HOST=0.0.0.0
- NUXT_PORT=3000
- DATABASE_URL=postgresql://whatsapp:${POSTGRES_PASSWORD}@${APP_NAME}-postgres:5432/whatsapp
- NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com
- MASTER_API_KEY=${MASTER_API_KEY}
networks:
- principal
labels:
- traefik.enable=true
- traefik.docker.network=principal
- traefik.http.services.evolution-api.loadbalancer.server.port=8080
- traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000
# Router: Manager UI protegido con Authentik (incluye rutas de callback)
- traefik.http.routers.evolution-ui.rule=Host(`${APP_DOMAIN}`) && (PathPrefix(`/manager`) || PathPrefix(`/outpost.goauthentik.io`))
- traefik.http.routers.evolution-ui.entrypoints=websecure
- traefik.http.routers.evolution-ui.tls.certresolver=letsencrypt
- traefik.http.routers.evolution-ui.service=evolution-api
- traefik.http.routers.evolution-ui.middlewares=authentik-forward-auth@file,evolution-headers
- traefik.http.routers.evolution-ui.priority=100
# Router: Recursos publicos (assets, manifest) - SIN autenticacion - ALTA PRIORIDAD
- traefik.http.routers.${APP_NAME}-public.rule=Host(`${APP_DOMAIN}`) && (PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/sw.js`) || PathPrefix(`/_nuxt/`) || PathPrefix(`/icons/`) || PathPrefix(`/favicon.ico`) || PathPrefix(`/api/health`))
- traefik.http.routers.${APP_NAME}-public.entrypoints=websecure
- traefik.http.routers.${APP_NAME}-public.tls.certresolver=letsencrypt
- traefik.http.routers.${APP_NAME}-public.service=${APP_NAME}
- traefik.http.routers.${APP_NAME}-public.priority=100
- traefik.http.routers.${APP_NAME}-public.middlewares=${APP_NAME}-headers
# Router: API endpoints (autenticación por API Key, sin Authentik)
- traefik.http.routers.evolution-api.rule=Host(`${APP_DOMAIN}`) && !PathPrefix(`/outpost.goauthentik.io`) && !PathPrefix(`/manager`)
- traefik.http.routers.evolution-api.entrypoints=websecure
- traefik.http.routers.evolution-api.tls.certresolver=letsencrypt
- traefik.http.routers.evolution-api.service=evolution-api
- traefik.http.routers.evolution-api.middlewares=evolution-headers
- traefik.http.routers.evolution-api.priority=10
# Router: API externa (usa API Key, sin Authentik) - MEDIA PRIORIDAD
- traefik.http.routers.${APP_NAME}-api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api/messages/send`)
- traefik.http.routers.${APP_NAME}-api.entrypoints=websecure
- traefik.http.routers.${APP_NAME}-api.tls.certresolver=letsencrypt
- traefik.http.routers.${APP_NAME}-api.service=${APP_NAME}
- traefik.http.routers.${APP_NAME}-api.priority=50
- traefik.http.routers.${APP_NAME}-api.middlewares=${APP_NAME}-headers
# Router: Principal (con Authentik) - BAJA PRIORIDAD
- traefik.http.routers.${APP_NAME}.rule=Host(`${APP_DOMAIN}`)
- traefik.http.routers.${APP_NAME}.entrypoints=websecure
- traefik.http.routers.${APP_NAME}.tls.certresolver=letsencrypt
- traefik.http.routers.${APP_NAME}.service=${APP_NAME}
- traefik.http.routers.${APP_NAME}.priority=10
- traefik.http.routers.${APP_NAME}.middlewares=authentik-forward-auth@file,${APP_NAME}-headers
# Middleware: Headers
- traefik.http.middlewares.evolution-headers.headers.customrequestheaders.X-Forwarded-Proto=https
- traefik.http.middlewares.${APP_NAME}-headers.headers.customrequestheaders.X-Forwarded-Proto=https
whatsapp-postgres:
image: postgres:16-alpine
container_name: ${APP_NAME}-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=whatsapp
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=whatsapp
volumes:
- whatsapp_postgres:/var/lib/postgresql/data
- ./server/database/init:/docker-entrypoint-initdb.d:ro
networks:
- principal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U whatsapp -d whatsapp"]
interval: 5s
timeout: 5s
retries: 5
volumes:
evolution_instances:
name: evolution_instances
evolution_store:
name: evolution_store
evolution_postgres:
name: evolution_postgres
whatsapp_postgres:
name: ${APP_NAME}_postgres
networks:
principal:

127
nuxt.config.ts Normal file
View File

@@ -0,0 +1,127 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
ssr: true,
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
modules: ['@nuxt/image', '@nuxt/ui', '@vite-pwa/nuxt'],
// Performance optimizations
experimental: {
payloadExtraction: false,
viewTransition: true
},
// Vite config
vite: {
server: {
hmr: {
clientPort: 443,
protocol: 'wss'
},
allowedHosts: [
'.nucleoriofrio.com',
'whatsapp.nucleoriofrio.com'
]
},
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
'vendor-ui': ['@nuxt/ui']
}
}
}
}
},
app: {
baseURL: process.env.BASE_URL || '/',
head: {
title: 'WhatsApp Nucleo',
link: [
{ rel: 'icon', type: 'image/png', href: '/icons/icon-192.png' },
{ rel: 'apple-touch-icon', sizes: '192x192', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/manifest.webmanifest' }
],
meta: [
{ name: 'theme-color', content: '#075e54' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
]
}
},
nitro: {
baseURL: process.env.BASE_URL || '/',
experimental: {
openAPI: true
},
routeRules: {
'/manifest.webmanifest': {
headers: {
'Content-Type': 'application/manifest+json',
'Cache-Control': 'public, max-age=3600'
}
},
'/sw.js': {
headers: {
'Service-Worker-Allowed': '/',
'Cache-Control': 'public, max-age=0'
}
}
}
},
pwa: {
registerType: 'autoUpdate',
strategies: 'generateSW',
manifestFilename: 'manifest.webmanifest',
manifest: {
id: '/?app=whatsapp',
name: 'WhatsApp Nucleo',
short_name: 'WhatsApp',
description: 'Gestiona multiples instancias de WhatsApp desde un panel centralizado',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#075e54',
theme_color: '#075e54',
launch_handler: {
client_mode: 'navigate-existing'
},
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-192-maskable.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg,webp,ico,json,woff2}'],
navigateFallback: undefined,
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024
},
client: {
installPrompt: true,
periodicSyncForUpdates: 3600
},
devOptions: {
enabled: process.env.NODE_ENV === 'development',
type: 'module'
}
},
runtimeConfig: {
// Server-side only
databaseUrl: process.env.DATABASE_URL || 'postgresql://whatsapp:password@localhost:5432/whatsapp',
masterApiKey: process.env.MASTER_API_KEY || '',
// Public (client + server)
public: {
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
}
}
})

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "whatsapp-nucleo",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.0",
"@vite-pwa/nuxt": "^0.9.1",
"@whiskeysockets/baileys": "^7.0.0-rc.9",
"nuxt": "^4.1.2",
"pg": "^8.13.1",
"pino": "^9.5.0",
"qrcode": "^1.5.4",
"typescript": "^5.9.2",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5"
}
}

View File

@@ -0,0 +1,29 @@
/**
* GET /api/auth/status
* Verifica el estado de autenticacion del usuario via Authentik headers
*/
export default defineEventHandler((event) => {
const headers = getHeaders(event)
const username = headers['x-authentik-username']
const email = headers['x-authentik-email']
const name = headers['x-authentik-name']
const groups = headers['x-authentik-groups']
if (!username) {
return {
authenticated: false,
user: null
}
}
return {
authenticated: true,
user: {
username,
email,
name,
groups: groups ? groups.split('|').filter((g: string) => g.trim()) : []
}
}
})

View File

@@ -0,0 +1,66 @@
/**
* GET /api/events/stream
* Server-Sent Events endpoint for real-time updates
*/
import { baileysManager } from '../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
// Set SSE headers
setHeader(event, 'Content-Type', 'text/event-stream')
setHeader(event, 'Cache-Control', 'no-cache')
setHeader(event, 'Connection', 'keep-alive')
// Get the raw response
const res = event.node.res
// Send initial connection message
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`)
// Event handlers
const handlers = {
'instance.status': (data: any) => {
res.write(`event: instance.status\ndata: ${JSON.stringify(data)}\n\n`)
},
'instance.qr': (data: any) => {
res.write(`event: instance.qr\ndata: ${JSON.stringify(data)}\n\n`)
},
'instance.pairing': (data: any) => {
res.write(`event: instance.pairing\ndata: ${JSON.stringify(data)}\n\n`)
},
'message.received': (data: any) => {
res.write(`event: message.received\ndata: ${JSON.stringify(data)}\n\n`)
},
'message.sent': (data: any) => {
res.write(`event: message.sent\ndata: ${JSON.stringify(data)}\n\n`)
},
'message.status': (data: any) => {
res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`)
}
}
// Register listeners
for (const [eventName, handler] of Object.entries(handlers)) {
baileysManager.on(eventName, handler)
}
// Keep-alive ping
const pingInterval = setInterval(() => {
res.write(`: ping\n\n`)
}, 30000)
// Cleanup on close
event.node.req.on('close', () => {
clearInterval(pingInterval)
for (const [eventName, handler] of Object.entries(handlers)) {
baileysManager.off(eventName, handler)
}
})
// Don't close the connection
return new Promise(() => {})
})

11
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* GET /api/health
* Health check endpoint
*/
export default defineEventHandler(() => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
}
})

View File

@@ -0,0 +1,54 @@
/**
* POST /api/instances/:id/connect
* Connect an instance (generates QR code)
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Check if instance exists
const result = await query<{ id: string; status: string }>(
'SELECT id, status FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
const instance = result.rows[0]
// Don't connect if already connected
if (instance.status === 'connected') {
throw createError({ statusCode: 400, message: 'Instance already connected' })
}
// Start connection
try {
await baileysManager.connect(id!)
// Wait a bit for QR to generate
await new Promise(resolve => setTimeout(resolve, 2000))
const qrCode = baileysManager.getQRCode(id!)
const status = baileysManager.getStatus(id!)
return {
success: true,
status: status?.status || 'connecting',
qrCode
}
} catch (error) {
throw createError({
statusCode: 500,
message: `Failed to connect: ${(error as Error).message}`
})
}
})

View File

@@ -0,0 +1,35 @@
/**
* POST /api/instances/:id/disconnect
* Disconnect an instance
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Check if instance exists
const result = await query<{ id: string }>(
'SELECT id FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
try {
await baileysManager.disconnect(id!)
return { success: true, status: 'disconnected' }
} catch (error) {
throw createError({
statusCode: 500,
message: `Failed to disconnect: ${(error as Error).message}`
})
}
})

View File

@@ -0,0 +1,41 @@
/**
* DELETE /api/instances/:id
* Delete an instance
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
import { clearAuthState } from '../../../services/baileys/auth-state'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Check if instance exists
const result = await query<{ id: string }>(
'SELECT id FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
// Disconnect if connected
try {
await baileysManager.disconnect(id!)
} catch (error) {
// Ignore disconnect errors
}
// Clear auth state
await clearAuthState(id!)
// Delete from database (cascades to related tables)
await query('DELETE FROM instances WHERE id = $1', [id])
return { success: true }
})

View File

@@ -0,0 +1,53 @@
/**
* GET /api/instances/:id
* Get instance details
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
interface InstanceRow {
id: string
name: string
phone_number: string | null
status: string
qr_code: string | null
pairing_code: string | null
last_connected_at: Date | null
created_by: string
created_at: Date
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
const result = await query<InstanceRow>(
`SELECT id, name, phone_number, status, qr_code, pairing_code,
last_connected_at, created_by, created_at
FROM instances WHERE id = $1`,
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
const instance = result.rows[0]
const liveStatus = baileysManager.getStatus(instance.id)
return {
id: instance.id,
name: instance.name,
phoneNumber: instance.phone_number,
status: liveStatus?.status || instance.status,
qrCode: liveStatus?.qrCode || instance.qr_code,
pairingCode: liveStatus?.pairingCode || instance.pairing_code,
lastConnectedAt: instance.last_connected_at,
createdBy: instance.created_by,
createdAt: instance.created_at
}
})

View File

@@ -0,0 +1,69 @@
/**
* POST /api/instances/:id/pairing-code
* Request a pairing code for connection without QR
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
interface PairingCodeBody {
phoneNumber: string
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
const body = await readBody<PairingCodeBody>(event)
if (!body.phoneNumber?.trim()) {
throw createError({ statusCode: 400, message: 'Phone number is required' })
}
// Clean phone number (remove +, spaces, dashes)
const cleanPhone = body.phoneNumber.replace(/[^0-9]/g, '')
// Check if instance exists
const result = await query<{ id: string; status: string }>(
'SELECT id, status FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
const instance = result.rows[0]
if (instance.status === 'connected') {
throw createError({ statusCode: 400, message: 'Instance already connected' })
}
try {
// Connect with pairing code mode
await baileysManager.connect(id!, true, cleanPhone)
// Wait for pairing code
await new Promise(resolve => setTimeout(resolve, 5000))
const pairingCode = baileysManager.getPairingCode(id!)
const status = baileysManager.getStatus(id!)
if (!pairingCode) {
throw new Error('Failed to generate pairing code')
}
return {
success: true,
code: pairingCode,
status: status?.status || 'pairing'
}
} catch (error) {
throw createError({
statusCode: 500,
message: `Failed to request pairing code: ${(error as Error).message}`
})
}
})

View File

@@ -0,0 +1,34 @@
/**
* GET /api/instances/:id/qr
* Get current QR code for an instance
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Check if instance exists
const result = await query<{ id: string; qr_code: string | null; status: string }>(
'SELECT id, qr_code, status FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
// Get live QR from manager
const qrCode = baileysManager.getQRCode(id!)
const status = baileysManager.getStatus(id!)
return {
qrCode: qrCode || result.rows[0].qr_code,
status: status?.status || result.rows[0].status
}
})

View File

@@ -0,0 +1,36 @@
/**
* GET /api/instances/:id/status
* Get instance connection status
*/
import { query } from '../../../utils/database'
import { baileysManager } from '../../../services/baileys/manager'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Check if instance exists
const result = await query<{ id: string; status: string; phone_number: string | null }>(
'SELECT id, status, phone_number FROM instances WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
const instance = result.rows[0]
const liveStatus = baileysManager.getStatus(id!)
return {
instanceId: id,
status: liveStatus?.status || instance.status,
phoneNumber: liveStatus?.phoneNumber || instance.phone_number,
hasQR: !!liveStatus?.qrCode,
hasPairingCode: !!liveStatus?.pairingCode
}
})

View File

@@ -0,0 +1,42 @@
/**
* GET /api/instances
* List all instances
*/
import { query } from '../../utils/database'
import { baileysManager } from '../../services/baileys/manager'
interface InstanceRow {
id: string
name: string
phone_number: string | null
status: string
last_connected_at: Date | null
created_at: Date
}
export default defineEventHandler(async (event) => {
// Check auth
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const result = await query<InstanceRow>(
`SELECT id, name, phone_number, status, last_connected_at, created_at
FROM instances
ORDER BY created_at DESC`
)
// Enrich with live status from manager
return result.rows.map(row => {
const liveStatus = baileysManager.getStatus(row.id)
return {
id: row.id,
name: row.name,
phoneNumber: row.phone_number,
status: liveStatus?.status || row.status,
lastConnectedAt: row.last_connected_at,
createdAt: row.created_at
}
})
})

View File

@@ -0,0 +1,48 @@
/**
* POST /api/instances
* Create a new instance
*/
import { query } from '../../utils/database'
interface CreateInstanceBody {
name: string
}
interface InstanceRow {
id: string
name: string
phone_number: string | null
status: string
created_at: Date
}
export default defineEventHandler(async (event) => {
// Check auth
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const body = await readBody<CreateInstanceBody>(event)
if (!body.name?.trim()) {
throw createError({ statusCode: 400, message: 'Name is required' })
}
const result = await query<InstanceRow>(
`INSERT INTO instances (name, created_by)
VALUES ($1, $2)
RETURNING id, name, phone_number, status, created_at`,
[body.name.trim(), username]
)
const instance = result.rows[0]
return {
id: instance.id,
name: instance.name,
phoneNumber: instance.phone_number,
status: instance.status,
createdAt: instance.created_at
}
})

View File

@@ -0,0 +1,72 @@
/**
* GET /api/messages/:instanceId/:chatId
* Get messages for a chat
*/
import { query } from '../../../../utils/database'
interface MessageRow {
id: string
message_id: string
from_jid: string
from_me: boolean
message_type: string
content: string | null
caption: string | null
media_url: string | null
timestamp: Date
status: string
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
const chatId = getRouterParam(event, 'chatId')
// Get query params for pagination
const queryParams = getQuery(event)
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
const offset = parseInt(queryParams.offset as string) || 0
// Verify chat exists and belongs to instance
const chatCheck = await query(
'SELECT id FROM chats WHERE id = $1 AND instance_id = $2',
[chatId, instanceId]
)
if (chatCheck.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Chat not found' })
}
// Get messages
const result = await query<MessageRow>(
`SELECT id, message_id, from_jid, from_me, message_type,
content, caption, media_url, timestamp, status
FROM messages
WHERE chat_id = $1
ORDER BY timestamp DESC
LIMIT $2 OFFSET $3`,
[chatId, limit, offset]
)
// Mark as read
await query(
'UPDATE chats SET unread_count = 0 WHERE id = $1',
[chatId]
)
return result.rows.map(row => ({
id: row.id,
messageId: row.message_id,
fromJid: row.from_jid,
fromMe: row.from_me,
type: row.message_type,
content: row.content,
caption: row.caption,
mediaUrl: row.media_url,
timestamp: row.timestamp,
status: row.status
}))
})

View File

@@ -0,0 +1,53 @@
/**
* POST /api/messages/:instanceId/:chatId/send
* Send a message to a chat (from UI)
*/
import { query } from '../../../../utils/database'
import { baileysManager } from '../../../../services/baileys/manager'
interface SendMessageBody {
message: string
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
const chatId = getRouterParam(event, 'chatId')
const body = await readBody<SendMessageBody>(event)
if (!body.message?.trim()) {
throw createError({ statusCode: 400, message: 'Message is required' })
}
// Get chat JID
const chatResult = await query<{ jid: string }>(
'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2',
[chatId, instanceId]
)
if (chatResult.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Chat not found' })
}
const jid = chatResult.rows[0].jid
try {
const result = await baileysManager.sendMessage(instanceId!, jid, {
text: body.message
})
return {
success: true,
messageId: result.key.id
}
} catch (error) {
throw createError({
statusCode: 500,
message: `Failed to send message: ${(error as Error).message}`
})
}
})

View File

@@ -0,0 +1,47 @@
/**
* GET /api/messages/:instanceId/chats
* Get all chats for an instance
*/
import { query } from '../../../utils/database'
interface ChatRow {
id: string
jid: string
name: string | null
is_group: boolean
unread_count: number
last_message_at: Date | null
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const instanceId = getRouterParam(event, 'instanceId')
// Verify instance exists
const instanceCheck = await query('SELECT id FROM instances WHERE id = $1', [instanceId])
if (instanceCheck.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Instance not found' })
}
// Get chats with last message
const result = await query<ChatRow>(
`SELECT c.id, c.jid, c.name, c.is_group, c.unread_count, c.last_message_at
FROM chats c
WHERE c.instance_id = $1
ORDER BY c.last_message_at DESC NULLS LAST`,
[instanceId]
)
return result.rows.map(row => ({
id: row.id,
jid: row.jid,
name: row.name || row.jid.split('@')[0],
isGroup: row.is_group,
unreadCount: row.unread_count,
lastMessageAt: row.last_message_at
}))
})

View File

@@ -0,0 +1,92 @@
/**
* POST /api/messages/send
* Send a message (External API - uses API Key)
*/
import { query } from '../../utils/database'
import { baileysManager } from '../../services/baileys/manager'
interface SendMessageBody {
instanceId: string
to: string
message: string
}
export default defineEventHandler(async (event) => {
// Check API Key authentication
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: 'API Key required' })
}
const apiKey = authHeader.slice(7)
const config = useRuntimeConfig()
// Check master API key
if (config.masterApiKey && apiKey === config.masterApiKey) {
// Master key - allowed
} else {
// Check database API keys
const crypto = await import('crypto')
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
const keyResult = await query(
`SELECT id, instance_id FROM api_keys
WHERE key_hash = $1 AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())`,
[keyHash]
)
if (keyResult.rows.length === 0) {
throw createError({ statusCode: 401, message: 'Invalid API Key' })
}
// Update last used
await query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
[keyResult.rows[0].id]
)
}
// Parse body
const body = await readBody<SendMessageBody>(event)
if (!body.instanceId) {
throw createError({ statusCode: 400, message: 'instanceId is required' })
}
if (!body.to) {
throw createError({ statusCode: 400, message: 'to is required' })
}
if (!body.message?.trim()) {
throw createError({ statusCode: 400, message: 'message is required' })
}
// Format JID
let jid = body.to.replace(/[^0-9]/g, '')
if (!jid.includes('@')) {
jid = `${jid}@s.whatsapp.net`
}
// Check instance exists and is connected
const status = baileysManager.getStatus(body.instanceId)
if (!status || status.status !== 'connected') {
throw createError({ statusCode: 400, message: 'Instance not connected' })
}
try {
const result = await baileysManager.sendMessage(body.instanceId, jid, {
text: body.message
})
return {
success: true,
messageId: result.key.id,
to: jid
}
} catch (error) {
throw createError({
statusCode: 500,
message: `Failed to send message: ${(error as Error).message}`
})
}
})

View File

@@ -0,0 +1,22 @@
/**
* DELETE /api/webhooks/:id
* Delete a webhook
*/
import { query } from '../../../utils/database'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
const result = await query('DELETE FROM webhooks WHERE id = $1 RETURNING id', [id])
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Webhook not found' })
}
return { success: true }
})

View File

@@ -0,0 +1,72 @@
/**
* PUT /api/webhooks/:id
* Update a webhook
*/
import { query } from '../../../utils/database'
interface UpdateWebhookBody {
name?: string
url?: string
secret?: string
events?: string[]
instanceId?: string | null
isActive?: boolean
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
const body = await readBody<UpdateWebhookBody>(event)
// Check if webhook exists
const existing = await query('SELECT id FROM webhooks WHERE id = $1', [id])
if (existing.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Webhook not found' })
}
// Build update query dynamically
const updates: string[] = []
const values: any[] = []
let paramIndex = 1
if (body.name !== undefined) {
updates.push(`name = $${paramIndex++}`)
values.push(body.name)
}
if (body.url !== undefined) {
updates.push(`url = $${paramIndex++}`)
values.push(body.url)
}
if (body.secret !== undefined) {
updates.push(`secret = $${paramIndex++}`)
values.push(body.secret || null)
}
if (body.events !== undefined) {
updates.push(`events = $${paramIndex++}`)
values.push(body.events)
}
if (body.instanceId !== undefined) {
updates.push(`instance_id = $${paramIndex++}`)
values.push(body.instanceId || null)
}
if (body.isActive !== undefined) {
updates.push(`is_active = $${paramIndex++}`)
values.push(body.isActive)
}
if (updates.length === 0) {
throw createError({ statusCode: 400, message: 'No fields to update' })
}
values.push(id)
await query(
`UPDATE webhooks SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex}`,
values
)
return { success: true }
})

View File

@@ -0,0 +1,100 @@
/**
* POST /api/webhooks/:id/test
* Test a webhook with a sample payload
*/
import { query } from '../../../utils/database'
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const id = getRouterParam(event, 'id')
// Get webhook
const result = await query<{ url: string; secret: string | null; headers: any }>(
'SELECT url, secret, headers FROM webhooks WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, message: 'Webhook not found' })
}
const webhook = result.rows[0]
// Create test payload
const testPayload = {
event: 'test',
timestamp: new Date().toISOString(),
data: {
message: 'This is a test webhook delivery',
webhookId: id,
sentBy: username
}
}
const body = JSON.stringify(testPayload)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Webhook-Event': 'test',
'X-Webhook-Timestamp': Date.now().toString(),
...(webhook.headers || {})
}
// Add signature if secret exists
if (webhook.secret) {
const crypto = await import('crypto')
const signature = crypto
.createHmac('sha256', webhook.secret)
.update(body)
.digest('hex')
headers['X-Webhook-Signature'] = `sha256=${signature}`
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body,
signal: controller.signal
})
clearTimeout(timeout)
const responseText = await response.text()
// Log the test
await query(
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, response_body, attempt, delivered_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[id, 'test', testPayload, response.status, responseText.slice(0, 1000), 1, new Date()]
)
return {
success: response.ok,
status: response.status,
statusText: response.statusText,
response: responseText.slice(0, 500)
}
} catch (error) {
const errorMessage = (error as Error).message
// Log the failure
await query(
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message, attempt)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, 'test', testPayload, 0, errorMessage, 1]
)
return {
success: false,
error: errorMessage
}
}
})

View File

@@ -0,0 +1,40 @@
/**
* GET /api/webhooks
* List all webhooks
*/
import { query } from '../../utils/database'
interface WebhookRow {
id: string
name: string
url: string
events: string[]
is_active: boolean
instance_id: string | null
created_at: Date
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const result = await query<WebhookRow>(
`SELECT w.id, w.name, w.url, w.events, w.is_active, w.instance_id, w.created_at,
i.name as instance_name
FROM webhooks w
LEFT JOIN instances i ON w.instance_id = i.id
ORDER BY w.created_at DESC`
)
return result.rows.map(row => ({
id: row.id,
name: row.name,
url: row.url,
events: row.events,
isActive: row.is_active,
instanceId: row.instance_id,
createdAt: row.created_at
}))
})

View File

@@ -0,0 +1,54 @@
/**
* POST /api/webhooks
* Create a webhook
*/
import { query } from '../../utils/database'
interface CreateWebhookBody {
name: string
url: string
secret?: string
events: string[]
instanceId?: string | null
}
export default defineEventHandler(async (event) => {
const username = getHeader(event, 'x-authentik-username')
if (!username) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const body = await readBody<CreateWebhookBody>(event)
if (!body.name?.trim()) {
throw createError({ statusCode: 400, message: 'Name is required' })
}
if (!body.url?.trim()) {
throw createError({ statusCode: 400, message: 'URL is required' })
}
if (!body.events?.length) {
throw createError({ statusCode: 400, message: 'At least one event is required' })
}
const result = await query<{ id: string }>(
`INSERT INTO webhooks (name, url, secret, events, instance_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[
body.name.trim(),
body.url.trim(),
body.secret || null,
body.events,
body.instanceId || null,
username
]
)
return {
id: result.rows[0].id,
name: body.name,
url: body.url,
events: body.events,
instanceId: body.instanceId || null
}
})

View File

@@ -0,0 +1,208 @@
-- =====================================================
-- WhatsApp Nucleo - Database Schema
-- =====================================================
-- Extension for UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =====================================================
-- INSTANCES: WhatsApp connection instances
-- =====================================================
CREATE TABLE instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
phone_number VARCHAR(20),
status VARCHAR(20) DEFAULT 'disconnected'
CHECK (status IN ('disconnected', 'connecting', 'connected', 'qr_ready', 'pairing')),
qr_code TEXT,
pairing_code VARCHAR(10),
last_connected_at TIMESTAMPTZ,
created_by VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_phone UNIQUE (phone_number)
);
-- =====================================================
-- AUTH_KEYS: Baileys credentials per instance
-- =====================================================
CREATE TABLE auth_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
key_type VARCHAR(50) NOT NULL,
key_id VARCHAR(100),
key_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_key UNIQUE (instance_id, key_type, key_id)
);
-- =====================================================
-- CONTACTS: WhatsApp contacts
-- =====================================================
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
jid VARCHAR(100) NOT NULL,
name VARCHAR(255),
push_name VARCHAR(255),
phone_number VARCHAR(20),
profile_picture_url TEXT,
is_group BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_contact UNIQUE (instance_id, jid)
);
-- =====================================================
-- CHATS: Conversations
-- =====================================================
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
jid VARCHAR(100) NOT NULL,
name VARCHAR(255),
is_group BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
is_pinned BOOLEAN DEFAULT FALSE,
unread_count INTEGER DEFAULT 0,
last_message_id UUID,
last_message_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_chat UNIQUE (instance_id, jid)
);
-- =====================================================
-- MESSAGES: All messages
-- =====================================================
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id VARCHAR(100) NOT NULL,
from_jid VARCHAR(100) NOT NULL,
to_jid VARCHAR(100),
from_me BOOLEAN DEFAULT FALSE,
message_type VARCHAR(50) NOT NULL,
content TEXT,
caption TEXT,
media_url TEXT,
media_mimetype VARCHAR(100),
media_filename VARCHAR(255),
quoted_message_id VARCHAR(100),
timestamp TIMESTAMPTZ NOT NULL,
status VARCHAR(20) DEFAULT 'sent'
CHECK (status IN ('pending', 'sent', 'delivered', 'read', 'failed')),
raw_message JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_message UNIQUE (instance_id, message_id)
);
-- =====================================================
-- WEBHOOKS: Webhook configurations
-- =====================================================
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID REFERENCES instances(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
url TEXT NOT NULL,
secret VARCHAR(255),
events TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE,
headers JSONB DEFAULT '{}',
retry_count INTEGER DEFAULT 3,
timeout_ms INTEGER DEFAULT 5000,
created_by VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =====================================================
-- WEBHOOK_LOGS: Delivery logs
-- =====================================================
CREATE TABLE webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
response_status INTEGER,
response_body TEXT,
error_message TEXT,
attempt INTEGER DEFAULT 1,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =====================================================
-- API_KEYS: External API access keys
-- =====================================================
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
key_prefix VARCHAR(10) NOT NULL,
instance_id UUID REFERENCES instances(id) ON DELETE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_by VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =====================================================
-- INDEXES for performance
-- =====================================================
CREATE INDEX idx_auth_keys_instance ON auth_keys(instance_id);
CREATE INDEX idx_contacts_instance ON contacts(instance_id);
CREATE INDEX idx_chats_instance ON chats(instance_id);
CREATE INDEX idx_chats_last_message ON chats(instance_id, last_message_at DESC);
CREATE INDEX idx_messages_instance_chat ON messages(instance_id, chat_id);
CREATE INDEX idx_messages_timestamp ON messages(timestamp DESC);
CREATE INDEX idx_messages_chat_timestamp ON messages(chat_id, timestamp DESC);
CREATE INDEX idx_webhooks_instance ON webhooks(instance_id);
CREATE INDEX idx_webhooks_active ON webhooks(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_webhook_logs_webhook ON webhook_logs(webhook_id, created_at DESC);
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix) WHERE is_active = TRUE;
-- =====================================================
-- TRIGGERS for updated_at
-- =====================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_instances_updated_at
BEFORE UPDATE ON instances
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_auth_keys_updated_at
BEFORE UPDATE ON auth_keys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_contacts_updated_at
BEFORE UPDATE ON contacts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_chats_updated_at
BEFORE UPDATE ON chats
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_webhooks_updated_at
BEFORE UPDATE ON webhooks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

18
server/plugins/baileys.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Nitro plugin to initialize Baileys manager on server start
*/
import { baileysManager } from '../services/baileys/manager'
export default defineNitroPlugin(async () => {
console.log('[Plugin] Initializing Baileys Manager...')
// Small delay to ensure database is ready
await new Promise(resolve => setTimeout(resolve, 2000))
try {
await baileysManager.initialize()
console.log('[Plugin] Baileys Manager initialized successfully')
} catch (error) {
console.error('[Plugin] Failed to initialize Baileys Manager:', error)
}
})

View File

@@ -0,0 +1,131 @@
/**
* PostgreSQL-based auth state for Baileys
* Stores credentials and keys in the database instead of files
*/
import type { AuthenticationCreds, SignalDataTypeMap } from '@whiskeysockets/baileys'
import { initAuthCreds, BufferJSON, proto } from '@whiskeysockets/baileys'
import { query } from '../../utils/database'
export interface PostgresAuthState {
state: {
creds: AuthenticationCreds
keys: {
get: <T extends keyof SignalDataTypeMap>(type: T, ids: string[]) => Promise<{ [id: string]: SignalDataTypeMap[T] }>
set: (data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }) => Promise<void>
}
}
saveCreds: () => Promise<void>
}
export async function usePostgresAuthState(instanceId: string): Promise<PostgresAuthState> {
// Load or create credentials
const loadCreds = async (): Promise<AuthenticationCreds> => {
const result = await query<{ key_data: any }>(
'SELECT key_data FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
[instanceId, 'creds', 'default']
)
if (result.rows.length > 0 && result.rows[0].key_data) {
return JSON.parse(JSON.stringify(result.rows[0].key_data), BufferJSON.reviver)
}
return initAuthCreds()
}
// Save credentials
const saveCreds = async (creds: AuthenticationCreds): Promise<void> => {
const data = JSON.parse(JSON.stringify(creds, BufferJSON.replacer))
await query(
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (instance_id, key_type, key_id)
DO UPDATE SET key_data = $4, updated_at = NOW()`,
[instanceId, 'creds', 'default', data]
)
}
// Load keys by type and ids
const loadKeys = async <T extends keyof SignalDataTypeMap>(
type: T,
ids: string[]
): Promise<{ [id: string]: SignalDataTypeMap[T] }> => {
const result: { [id: string]: SignalDataTypeMap[T] } = {}
if (ids.length === 0) return result
const placeholders = ids.map((_, i) => `$${i + 3}`).join(', ')
const queryResult = await query<{ key_id: string; key_data: any }>(
`SELECT key_id, key_data FROM auth_keys
WHERE instance_id = $1 AND key_type = $2 AND key_id IN (${placeholders})`,
[instanceId, type, ...ids]
)
for (const row of queryResult.rows) {
let value = JSON.parse(JSON.stringify(row.key_data), BufferJSON.reviver)
// Handle special types
if (type === 'app-state-sync-key' && value) {
value = proto.Message.AppStateSyncKeyData.fromObject(value)
}
result[row.key_id] = value
}
return result
}
// Save keys
const saveKeys = async (
data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }
): Promise<void> => {
for (const type in data) {
for (const id in data[type]) {
const value = data[type][id]
if (value === null) {
// Delete key
await query(
'DELETE FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
[instanceId, type, id]
)
} else {
// Upsert key
const serialized = JSON.parse(JSON.stringify(value, BufferJSON.replacer))
await query(
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (instance_id, key_type, key_id)
DO UPDATE SET key_data = $4, updated_at = NOW()`,
[instanceId, type, id, serialized]
)
}
}
}
}
// Load initial credentials
const creds = await loadCreds()
return {
state: {
creds,
keys: {
get: loadKeys,
set: saveKeys
}
},
saveCreds: async () => {
await saveCreds(creds)
}
}
}
/**
* Clear all auth data for an instance
*/
export async function clearAuthState(instanceId: string): Promise<void> {
await query(
'DELETE FROM auth_keys WHERE instance_id = $1',
[instanceId]
)
}

View File

@@ -0,0 +1,456 @@
/**
* BaileysManager - Manages multiple WhatsApp instances
* Singleton pattern for managing all Baileys connections
*/
import makeWASocket, {
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
type WASocket,
type BaileysEventMap,
Browsers
} from '@whiskeysockets/baileys'
import { Boom } from '@hapi/boom'
import { EventEmitter } from 'events'
import QRCode from 'qrcode'
import pino from 'pino'
import { usePostgresAuthState, clearAuthState } from './auth-state'
import { query } from '../../utils/database'
// Types
export interface ManagedInstance {
id: string
name: string
socket: WASocket | null
status: 'disconnected' | 'connecting' | 'connected' | 'qr_ready' | 'pairing'
phoneNumber: string | null
qrCode: string | null
pairingCode: string | null
reconnectAttempts: number
lastError: string | null
}
export interface InstanceEvents {
'instance.status': { instanceId: string; status: string; phoneNumber?: string }
'instance.qr': { instanceId: string; qr: string; qrDataUrl: string }
'instance.pairing': { instanceId: string; code: string }
'message.received': { instanceId: string; message: any }
'message.sent': { instanceId: string; message: any }
'message.status': { instanceId: string; messageId: string; status: string }
}
const logger = pino({ level: 'warn' })
class BaileysManager extends EventEmitter {
private instances: Map<string, ManagedInstance> = new Map()
private static instance: BaileysManager | null = null
private initialized = false
private constructor() {
super()
}
static getInstance(): BaileysManager {
if (!BaileysManager.instance) {
BaileysManager.instance = new BaileysManager()
}
return BaileysManager.instance
}
/**
* Initialize manager and reconnect previously connected instances
*/
async initialize(): Promise<void> {
if (this.initialized) return
console.log('[BaileysManager] Initializing...')
try {
// Load instances that were previously connected
const result = await query<{ id: string; name: string; phone_number: string }>(
`SELECT id, name, phone_number FROM instances WHERE status = 'connected'`
)
for (const row of result.rows) {
console.log(`[BaileysManager] Reconnecting instance: ${row.name}`)
await this.connect(row.id)
}
this.initialized = true
console.log('[BaileysManager] Initialized successfully')
} catch (error) {
console.error('[BaileysManager] Initialization error:', error)
}
}
/**
* Connect an instance to WhatsApp
*/
async connect(instanceId: string, usePairingCode = false, phoneNumber?: string): Promise<void> {
// Check if already connected
const existing = this.instances.get(instanceId)
if (existing?.socket) {
console.log(`[BaileysManager] Instance ${instanceId} already has active socket`)
return
}
// Load instance from DB
const instanceResult = await query<{ id: string; name: string; phone_number: string }>(
'SELECT id, name, phone_number FROM instances WHERE id = $1',
[instanceId]
)
if (instanceResult.rows.length === 0) {
throw new Error(`Instance ${instanceId} not found`)
}
const instanceData = instanceResult.rows[0]
// Update status
await this.updateInstanceStatus(instanceId, 'connecting')
// Create managed instance
const managed: ManagedInstance = {
id: instanceId,
name: instanceData.name,
socket: null,
status: 'connecting',
phoneNumber: instanceData.phone_number || null,
qrCode: null,
pairingCode: null,
reconnectAttempts: 0,
lastError: null
}
this.instances.set(instanceId, managed)
try {
// Load auth state from PostgreSQL
const { state, saveCreds } = await usePostgresAuthState(instanceId)
const { version } = await fetchLatestBaileysVersion()
// Create socket
const socket = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger)
},
printQRInTerminal: false,
browser: Browsers.ubuntu('WhatsApp Nucleo'),
logger,
generateHighQualityLinkPreview: true,
syncFullHistory: false,
markOnlineOnConnect: false
})
managed.socket = socket
// Setup event handlers
this.setupEventHandlers(instanceId, socket, saveCreds, usePairingCode, phoneNumber)
} catch (error) {
console.error(`[BaileysManager] Error connecting instance ${instanceId}:`, error)
managed.lastError = (error as Error).message
await this.updateInstanceStatus(instanceId, 'disconnected')
throw error
}
}
/**
* Setup event handlers for a socket
*/
private setupEventHandlers(
instanceId: string,
socket: WASocket,
saveCreds: () => Promise<void>,
usePairingCode: boolean,
phoneNumber?: string
): void {
const managed = this.instances.get(instanceId)!
// Credentials update
socket.ev.on('creds.update', saveCreds)
// Connection update
socket.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update
// QR Code received
if (qr && !usePairingCode) {
const qrDataUrl = await QRCode.toDataURL(qr)
managed.qrCode = qrDataUrl
managed.status = 'qr_ready'
await this.updateInstanceStatus(instanceId, 'qr_ready', { qr_code: qrDataUrl })
this.emit('instance.qr', { instanceId, qr, qrDataUrl })
}
// Request pairing code if needed
if (usePairingCode && phoneNumber && !managed.pairingCode && connection === 'connecting') {
try {
// Wait a bit for socket to be ready
await new Promise(resolve => setTimeout(resolve, 3000))
if (!socket.authState.creds.registered) {
const code = await socket.requestPairingCode(phoneNumber)
managed.pairingCode = code
managed.status = 'pairing'
await this.updateInstanceStatus(instanceId, 'pairing', { pairing_code: code })
this.emit('instance.pairing', { instanceId, code })
}
} catch (error) {
console.error(`[BaileysManager] Error requesting pairing code:`, error)
}
}
// Connection closed
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode
const shouldReconnect = statusCode !== DisconnectReason.loggedOut
managed.socket = null
managed.qrCode = null
managed.pairingCode = null
if (shouldReconnect && managed.reconnectAttempts < 5) {
managed.reconnectAttempts++
console.log(`[BaileysManager] Reconnecting instance ${instanceId} (attempt ${managed.reconnectAttempts})`)
// Wait before reconnecting
await new Promise(resolve => setTimeout(resolve, 3000 * managed.reconnectAttempts))
await this.connect(instanceId)
} else {
managed.status = 'disconnected'
await this.updateInstanceStatus(instanceId, 'disconnected')
if (statusCode === DisconnectReason.loggedOut) {
// Clear auth state if logged out
await clearAuthState(instanceId)
}
}
this.emit('instance.status', { instanceId, status: 'disconnected' })
}
// Connection open
if (connection === 'open') {
const phoneNum = socket.user?.id.split(':')[0] || null
managed.status = 'connected'
managed.phoneNumber = phoneNum
managed.qrCode = null
managed.pairingCode = null
managed.reconnectAttempts = 0
await this.updateInstanceStatus(instanceId, 'connected', {
phone_number: phoneNum,
qr_code: null,
pairing_code: null,
last_connected_at: new Date()
})
this.emit('instance.status', { instanceId, status: 'connected', phoneNumber: phoneNum || undefined })
console.log(`[BaileysManager] Instance ${instanceId} connected as ${phoneNum}`)
}
})
// Messages received
socket.ev.on('messages.upsert', async ({ messages, type }) => {
for (const msg of messages) {
// Emit event for webhooks
this.emit('message.received', { instanceId, message: msg })
// Save message to database
await this.saveMessage(instanceId, msg, type === 'notify')
}
})
// Message status update
socket.ev.on('messages.update', async (updates) => {
for (const update of updates) {
if (update.update.status) {
this.emit('message.status', {
instanceId,
messageId: update.key.id!,
status: this.mapMessageStatus(update.update.status)
})
}
}
})
}
/**
* Disconnect an instance
*/
async disconnect(instanceId: string): Promise<void> {
const managed = this.instances.get(instanceId)
if (!managed?.socket) return
managed.socket.end(new Error('Disconnected by user'))
managed.socket = null
managed.status = 'disconnected'
managed.qrCode = null
managed.pairingCode = null
await this.updateInstanceStatus(instanceId, 'disconnected')
this.emit('instance.status', { instanceId, status: 'disconnected' })
}
/**
* Send a message
*/
async sendMessage(instanceId: string, jid: string, content: any): Promise<any> {
const managed = this.instances.get(instanceId)
if (!managed?.socket) {
throw new Error('Instance not connected')
}
const result = await managed.socket.sendMessage(jid, content)
this.emit('message.sent', { instanceId, message: result })
return result
}
/**
* Get QR code for an instance
*/
getQRCode(instanceId: string): string | null {
return this.instances.get(instanceId)?.qrCode || null
}
/**
* Get pairing code for an instance
*/
getPairingCode(instanceId: string): string | null {
return this.instances.get(instanceId)?.pairingCode || null
}
/**
* Get instance status
*/
getStatus(instanceId: string): ManagedInstance | null {
return this.instances.get(instanceId) || null
}
/**
* Get all instances
*/
getAllInstances(): ManagedInstance[] {
return Array.from(this.instances.values())
}
/**
* Update instance status in database
*/
private async updateInstanceStatus(
instanceId: string,
status: string,
extra: Record<string, any> = {}
): Promise<void> {
const fields = ['status = $2']
const values: any[] = [instanceId, status]
let paramIndex = 3
for (const [key, value] of Object.entries(extra)) {
fields.push(`${key} = $${paramIndex}`)
values.push(value)
paramIndex++
}
await query(
`UPDATE instances SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`,
values
)
}
/**
* Save message to database
*/
private async saveMessage(instanceId: string, msg: any, isNew: boolean): Promise<void> {
try {
const jid = msg.key.remoteJid
if (!jid) return
// Ensure chat exists
const chatResult = await query<{ id: string }>(
`INSERT INTO chats (instance_id, jid, name, is_group)
VALUES ($1, $2, $3, $4)
ON CONFLICT (instance_id, jid) DO UPDATE SET updated_at = NOW()
RETURNING id`,
[instanceId, jid, msg.pushName || jid.split('@')[0], jid.includes('@g.us')]
)
const chatId = chatResult.rows[0].id
// Get message content
const content = msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
''
const messageType = this.getMessageType(msg.message)
// Insert message
await query(
`INSERT INTO messages (
instance_id, chat_id, message_id, from_jid, from_me,
message_type, content, timestamp, raw_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (instance_id, message_id) DO NOTHING`,
[
instanceId,
chatId,
msg.key.id,
msg.key.fromMe ? 'me' : jid,
msg.key.fromMe || false,
messageType,
content,
new Date(msg.messageTimestamp! * 1000),
JSON.stringify(msg)
]
)
// Update chat last message
if (isNew) {
await query(
`UPDATE chats SET last_message_at = $1, unread_count = unread_count + 1 WHERE id = $2`,
[new Date(msg.messageTimestamp! * 1000), chatId]
)
}
} catch (error) {
console.error('[BaileysManager] Error saving message:', error)
}
}
/**
* Get message type from Baileys message object
*/
private getMessageType(message: any): string {
if (!message) return 'unknown'
if (message.conversation || message.extendedTextMessage) return 'text'
if (message.imageMessage) return 'image'
if (message.videoMessage) return 'video'
if (message.audioMessage) return 'audio'
if (message.documentMessage) return 'document'
if (message.stickerMessage) return 'sticker'
if (message.contactMessage) return 'contact'
if (message.locationMessage) return 'location'
return 'unknown'
}
/**
* Map Baileys message status to our status
*/
private mapMessageStatus(status: number): string {
switch (status) {
case 0: return 'pending'
case 1: return 'sent'
case 2: return 'delivered'
case 3: return 'read'
case 4: return 'read'
default: return 'sent'
}
}
}
// Export singleton
export const baileysManager = BaileysManager.getInstance()

View File

@@ -0,0 +1,171 @@
/**
* Webhook Dispatcher
* Sends events to configured webhooks
*/
import crypto from 'crypto'
import { query } from '../../utils/database'
import { baileysManager } from '../baileys/manager'
interface Webhook {
id: string
url: string
secret: string | null
events: string[]
headers: Record<string, string>
retry_count: number
timeout_ms: number
instance_id: string | null
}
class WebhookDispatcher {
private initialized = false
async initialize() {
if (this.initialized) return
// Listen to all Baileys events
const events = [
'message.received',
'message.sent',
'message.status',
'instance.connected',
'instance.disconnected',
'instance.status',
'instance.qr'
]
for (const eventType of events) {
baileysManager.on(eventType, (data: any) => {
this.dispatch(data.instanceId || null, eventType, data)
})
}
this.initialized = true
console.log('[WebhookDispatcher] Initialized')
}
async dispatch(instanceId: string | null, eventType: string, payload: any) {
try {
// Get matching webhooks
const webhooks = await query<Webhook>(
`SELECT id, url, secret, events, headers, retry_count, timeout_ms, instance_id
FROM webhooks
WHERE is_active = TRUE
AND $1 = ANY(events)
AND (instance_id IS NULL OR instance_id = $2)`,
[eventType, instanceId]
)
for (const webhook of webhooks.rows) {
this.deliverWithRetry(webhook, eventType, payload)
}
} catch (error) {
console.error('[WebhookDispatcher] Error dispatching:', error)
}
}
private async deliverWithRetry(
webhook: Webhook,
eventType: string,
payload: any,
attempt = 1
) {
try {
await this.deliver(webhook, eventType, payload)
// Log success
await this.logDelivery(webhook.id, eventType, payload, {
status: 200,
attempt,
success: true
})
} catch (error) {
const errorMessage = (error as Error).message
// Log failure
await this.logDelivery(webhook.id, eventType, payload, {
status: 0,
attempt,
success: false,
error: errorMessage
})
// Retry if under limit
if (attempt < webhook.retry_count) {
const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
setTimeout(() => {
this.deliverWithRetry(webhook, eventType, payload, attempt + 1)
}, delay)
}
}
}
private async deliver(webhook: Webhook, eventType: string, payload: any) {
const body = JSON.stringify({
event: eventType,
timestamp: new Date().toISOString(),
data: payload
})
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Webhook-Event': eventType,
'X-Webhook-Timestamp': Date.now().toString(),
...(webhook.headers || {})
}
// Add HMAC signature if secret is configured
if (webhook.secret) {
const signature = crypto
.createHmac('sha256', webhook.secret)
.update(body)
.digest('hex')
headers['X-Webhook-Signature'] = `sha256=${signature}`
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body,
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} finally {
clearTimeout(timeout)
}
}
private async logDelivery(
webhookId: string,
eventType: string,
payload: any,
result: { status: number; attempt: number; success: boolean; error?: string }
) {
try {
await query(
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message, attempt, delivered_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
webhookId,
eventType,
JSON.stringify(payload),
result.status,
result.error || null,
result.attempt,
result.success ? new Date() : null
]
)
} catch (error) {
console.error('[WebhookDispatcher] Error logging delivery:', error)
}
}
}
export const webhookDispatcher = new WebhookDispatcher()

52
server/utils/database.ts Normal file
View File

@@ -0,0 +1,52 @@
import pg from 'pg'
const { Pool } = pg
let pool: pg.Pool | null = null
export function getPool(): pg.Pool {
if (!pool) {
const config = useRuntimeConfig()
pool = new Pool({
connectionString: config.databaseUrl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
})
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
})
}
return pool
}
export async function query<T = any>(
text: string,
params?: any[]
): Promise<pg.QueryResult<T>> {
const client = await getPool().connect()
try {
return await client.query<T>(text, params)
} finally {
client.release()
}
}
export async function transaction<T>(
callback: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
const client = await getPool().connect()
try {
await client.query('BEGIN')
const result = await callback(client)
await client.query('COMMIT')
return result
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
}

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}