feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
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:
19
.env.example
19
.env.example
@@ -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
|
||||
|
||||
@@ -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
27
.gitignore
vendored
@@ -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
37
Dockerfile
Normal 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
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
20
app/app.vue
Normal file
20
app/app.vue
Normal 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
94
app/assets/css/main.css
Normal 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;
|
||||
}
|
||||
59
app/components/app/AppSidebar.vue
Normal file
59
app/components/app/AppSidebar.vue
Normal 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>
|
||||
24
app/components/app/ConnectionStatus.vue
Normal file
24
app/components/app/ConnectionStatus.vue
Normal 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>
|
||||
36
app/components/app/UserMenu.vue
Normal file
36
app/components/app/UserMenu.vue
Normal 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>
|
||||
54
app/components/common/MetricCard.vue
Normal file
54
app/components/common/MetricCard.vue
Normal 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>
|
||||
42
app/components/common/StatusBadge.vue
Normal file
42
app/components/common/StatusBadge.vue
Normal 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>
|
||||
91
app/components/instances/CreateInstanceModal.vue
Normal file
91
app/components/instances/CreateInstanceModal.vue
Normal 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>
|
||||
117
app/components/instances/InstanceCard.vue
Normal file
117
app/components/instances/InstanceCard.vue
Normal 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>
|
||||
111
app/components/instances/QRCodeModal.vue
Normal file
111
app/components/instances/QRCodeModal.vue
Normal 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>
|
||||
68
app/components/messages/ChatItem.vue
Normal file
68
app/components/messages/ChatItem.vue
Normal 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>
|
||||
85
app/components/messages/MessageBubble.vue
Normal file
85
app/components/messages/MessageBubble.vue
Normal 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>
|
||||
45
app/components/messages/MessageInput.vue
Normal file
45
app/components/messages/MessageInput.vue
Normal 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>
|
||||
107
app/components/webhooks/WebhookCard.vue
Normal file
107
app/components/webhooks/WebhookCard.vue
Normal 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>
|
||||
182
app/components/webhooks/WebhookFormModal.vue
Normal file
182
app/components/webhooks/WebhookFormModal.vue
Normal 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>
|
||||
68
app/composables/useAuthentik.ts
Normal file
68
app/composables/useAuthentik.ts
Normal 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
|
||||
}
|
||||
}
|
||||
124
app/composables/useInstances.ts
Normal file
124
app/composables/useInstances.ts
Normal 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
|
||||
}
|
||||
}
|
||||
22
app/composables/useSidebarState.ts
Normal file
22
app/composables/useSidebarState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
4
app/composables/useToast.ts
Normal file
4
app/composables/useToast.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Re-export useToast from Nuxt UI
|
||||
*/
|
||||
export { useToast } from '#imports'
|
||||
39
app/layouts/dashboard.vue
Normal file
39
app/layouts/dashboard.vue
Normal 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
103
app/pages/api-docs.vue
Normal 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>
|
||||
-H <span class="text-[var(--wa-green-light)]">"Authorization: Bearer YOUR_API_KEY"</span> \<br>
|
||||
-H <span class="text-[var(--wa-green-light)]">"Content-Type: application/json"</span> \<br>
|
||||
-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>
|
||||
"instanceId": <span class="text-[var(--wa-green-light)]">"uuid-de-instancia"</span>,<br>
|
||||
"to": <span class="text-[var(--wa-green-light)]">"5491123456789"</span>,<br>
|
||||
"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
113
app/pages/index.vue
Normal 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>
|
||||
91
app/pages/instances/index.vue
Normal file
91
app/pages/instances/index.vue
Normal 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>
|
||||
116
app/pages/messages/index.vue
Normal file
116
app/pages/messages/index.vue
Normal 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
138
app/pages/settings.vue
Normal 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>
|
||||
86
app/pages/webhooks/index.vue
Normal file
86
app/pages/webhooks/index.vue
Normal 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>
|
||||
@@ -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
127
nuxt.config.ts
Normal 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
29
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
server/api/auth/status.get.ts
Normal file
29
server/api/auth/status.get.ts
Normal 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()) : []
|
||||
}
|
||||
}
|
||||
})
|
||||
66
server/api/events/stream.get.ts
Normal file
66
server/api/events/stream.get.ts
Normal 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
11
server/api/health.get.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
54
server/api/instances/[id]/connect.post.ts
Normal file
54
server/api/instances/[id]/connect.post.ts
Normal 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
35
server/api/instances/[id]/disconnect.post.ts
Normal file
35
server/api/instances/[id]/disconnect.post.ts
Normal 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
41
server/api/instances/[id]/index.delete.ts
Normal file
41
server/api/instances/[id]/index.delete.ts
Normal 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 }
|
||||
})
|
||||
53
server/api/instances/[id]/index.get.ts
Normal file
53
server/api/instances/[id]/index.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
69
server/api/instances/[id]/pairing-code.post.ts
Normal file
69
server/api/instances/[id]/pairing-code.post.ts
Normal 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
34
server/api/instances/[id]/qr.get.ts
Normal file
34
server/api/instances/[id]/qr.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
36
server/api/instances/[id]/status.get.ts
Normal file
36
server/api/instances/[id]/status.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
42
server/api/instances/index.get.ts
Normal file
42
server/api/instances/index.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
48
server/api/instances/index.post.ts
Normal file
48
server/api/instances/index.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
72
server/api/messages/[instanceId]/[chatId]/index.get.ts
Normal file
72
server/api/messages/[instanceId]/[chatId]/index.get.ts
Normal 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
|
||||
}))
|
||||
})
|
||||
53
server/api/messages/[instanceId]/[chatId]/send.post.ts
Normal file
53
server/api/messages/[instanceId]/[chatId]/send.post.ts
Normal 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
47
server/api/messages/[instanceId]/chats.get.ts
Normal file
47
server/api/messages/[instanceId]/chats.get.ts
Normal 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
|
||||
}))
|
||||
})
|
||||
92
server/api/messages/send.post.ts
Normal file
92
server/api/messages/send.post.ts
Normal 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
22
server/api/webhooks/[id]/index.delete.ts
Normal file
22
server/api/webhooks/[id]/index.delete.ts
Normal 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 }
|
||||
})
|
||||
72
server/api/webhooks/[id]/index.put.ts
Normal file
72
server/api/webhooks/[id]/index.put.ts
Normal 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 }
|
||||
})
|
||||
100
server/api/webhooks/[id]/test.post.ts
Normal file
100
server/api/webhooks/[id]/test.post.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
40
server/api/webhooks/index.get.ts
Normal file
40
server/api/webhooks/index.get.ts
Normal 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
|
||||
}))
|
||||
})
|
||||
54
server/api/webhooks/index.post.ts
Normal file
54
server/api/webhooks/index.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
208
server/database/init/001_schema.sql
Normal file
208
server/database/init/001_schema.sql
Normal 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
18
server/plugins/baileys.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
131
server/services/baileys/auth-state.ts
Normal file
131
server/services/baileys/auth-state.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
456
server/services/baileys/manager.ts
Normal file
456
server/services/baileys/manager.ts
Normal 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()
|
||||
171
server/services/webhooks/dispatcher.ts
Normal file
171
server/services/webhooks/dispatcher.ts
Normal 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
52
server/utils/database.ts
Normal 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
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user