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
|
APP_DOMAIN=whatsapp.nucleoriofrio.com
|
||||||
|
|
||||||
# Evolution API - Genera una key segura
|
# Registry (for Gitea CI/CD)
|
||||||
EVOLUTION_API_KEY=tu-api-key-segura-aqui
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
#───────────────── deploy (unified) ─────────────────
|
build-and-deploy:
|
||||||
deploy:
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
APP_NAME: ${{ vars.APP_NAME }}
|
APP_NAME: ${{ vars.APP_NAME }}
|
||||||
APP_DOMAIN: ${{ vars.APP_DOMAIN }}
|
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 }}
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Info about environment
|
- name: Info about environment
|
||||||
run: |
|
run: |
|
||||||
echo "ℹ️ Deploying ${{ vars.APP_NAME }}"
|
echo "Building ${{ vars.APP_NAME }}"
|
||||||
echo " Domain: ${{ vars.APP_DOMAIN }}"
|
echo "Domain: ${{ vars.APP_DOMAIN }}"
|
||||||
echo " Network: principal"
|
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
|
run: docker compose pull
|
||||||
|
|
||||||
- name: Clean up stack
|
- name: Stop existing stack
|
||||||
run: docker compose --project-name $APP_NAME down
|
run: docker compose --project-name $APP_NAME down --remove-orphans || true
|
||||||
|
|
||||||
- name: Update stack
|
- name: Start new stack
|
||||||
run: docker compose --project-name $APP_NAME up -d --remove-orphans --wait
|
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.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude
|
||||||
.claude/settings.local.json
|
.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:
|
services:
|
||||||
evolution-postgres:
|
whatsapp-nucleo:
|
||||||
image: postgres:15-alpine
|
build: .
|
||||||
container_name: evolution-postgres
|
image: ${REG}/${REPO_OWNER}/${APP_NAME}:latest
|
||||||
restart: unless-stopped
|
container_name: ${APP_NAME}
|
||||||
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
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
evolution-postgres:
|
whatsapp-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# Configuración básica
|
- NODE_ENV=production
|
||||||
- SERVER_URL=https://${APP_DOMAIN}
|
- NUXT_HOST=0.0.0.0
|
||||||
- AUTHENTICATION_TYPE=apikey
|
- NUXT_PORT=3000
|
||||||
- AUTHENTICATION_API_KEY=${EVOLUTION_API_KEY}
|
- DATABASE_URL=postgresql://whatsapp:${POSTGRES_PASSWORD}@${APP_NAME}-postgres:5432/whatsapp
|
||||||
- AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES=true
|
- NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com
|
||||||
|
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||||
# 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
|
|
||||||
networks:
|
networks:
|
||||||
- principal
|
- principal
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=principal
|
- 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)
|
# Router: Recursos publicos (assets, manifest) - SIN autenticacion - ALTA PRIORIDAD
|
||||||
- traefik.http.routers.evolution-ui.rule=Host(`${APP_DOMAIN}`) && (PathPrefix(`/manager`) || PathPrefix(`/outpost.goauthentik.io`))
|
- 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.evolution-ui.entrypoints=websecure
|
- traefik.http.routers.${APP_NAME}-public.entrypoints=websecure
|
||||||
- traefik.http.routers.evolution-ui.tls.certresolver=letsencrypt
|
- traefik.http.routers.${APP_NAME}-public.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.evolution-ui.service=evolution-api
|
- traefik.http.routers.${APP_NAME}-public.service=${APP_NAME}
|
||||||
- traefik.http.routers.evolution-ui.middlewares=authentik-forward-auth@file,evolution-headers
|
- traefik.http.routers.${APP_NAME}-public.priority=100
|
||||||
- traefik.http.routers.evolution-ui.priority=100
|
- traefik.http.routers.${APP_NAME}-public.middlewares=${APP_NAME}-headers
|
||||||
|
|
||||||
# Router: API endpoints (autenticación por API Key, sin Authentik)
|
# Router: API externa (usa API Key, sin Authentik) - MEDIA PRIORIDAD
|
||||||
- traefik.http.routers.evolution-api.rule=Host(`${APP_DOMAIN}`) && !PathPrefix(`/outpost.goauthentik.io`) && !PathPrefix(`/manager`)
|
- traefik.http.routers.${APP_NAME}-api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api/messages/send`)
|
||||||
- traefik.http.routers.evolution-api.entrypoints=websecure
|
- traefik.http.routers.${APP_NAME}-api.entrypoints=websecure
|
||||||
- traefik.http.routers.evolution-api.tls.certresolver=letsencrypt
|
- traefik.http.routers.${APP_NAME}-api.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.evolution-api.service=evolution-api
|
- traefik.http.routers.${APP_NAME}-api.service=${APP_NAME}
|
||||||
- traefik.http.routers.evolution-api.middlewares=evolution-headers
|
- traefik.http.routers.${APP_NAME}-api.priority=50
|
||||||
- traefik.http.routers.evolution-api.priority=10
|
- 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
|
# 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:
|
volumes:
|
||||||
evolution_instances:
|
whatsapp_postgres:
|
||||||
name: evolution_instances
|
name: ${APP_NAME}_postgres
|
||||||
evolution_store:
|
|
||||||
name: evolution_store
|
|
||||||
evolution_postgres:
|
|
||||||
name: evolution_postgres
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
principal:
|
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