diff --git a/.env.example b/.env.example index ae4c1bc..c1c971b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,18 @@ -# Dominio +# WhatsApp Nucleo - Environment Variables + +# Domain +APP_NAME=whatsapp-nucleo APP_DOMAIN=whatsapp.nucleoriofrio.com -# Evolution API - Genera una key segura -EVOLUTION_API_KEY=tu-api-key-segura-aqui +# Registry (for Gitea CI/CD) +REG=git.nucleoriofrio.com +REPO_OWNER=nucleo + +# PostgreSQL +POSTGRES_PASSWORD=change-this-secure-password + +# Master API Key (for external API access) +MASTER_API_KEY=change-this-api-key + +# Authentik (optional, for development) +NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 49572ac..ac98998 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,32 +1,61 @@ -name: deploy +name: Build and Deploy on: push: branches: [ main, master ] jobs: -#───────────────── deploy (unified) ───────────────── - deploy: + build-and-deploy: runs-on: docker env: APP_NAME: ${{ vars.APP_NAME }} APP_DOMAIN: ${{ vars.APP_DOMAIN }} - EVOLUTION_API_KEY: ${{ secrets.EVOLUTION_API_KEY }} + REG: ${{ vars.REGISTRY_URL }} + REPO_OWNER: ${{ vars.REPO_OWNER }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }} + steps: - uses: actions/checkout@v3 - name: Info about environment run: | - echo "ℹ️ Deploying ${{ vars.APP_NAME }}" - echo " Domain: ${{ vars.APP_DOMAIN }}" - echo " Network: principal" + echo "Building ${{ vars.APP_NAME }}" + echo "Domain: ${{ vars.APP_DOMAIN }}" + echo "Registry: ${{ vars.REGISTRY_URL }}" - - name: Pull fresh images used in compose + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:latest + ${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:${{ github.sha }} + cache-from: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:buildcache + cache-to: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ vars.REPO_OWNER }}/${{ vars.APP_NAME }}:buildcache,mode=max + + - name: Pull fresh images run: docker compose pull - - name: Clean up stack - run: docker compose --project-name $APP_NAME down + - name: Stop existing stack + run: docker compose --project-name $APP_NAME down --remove-orphans || true - - name: Update stack - run: docker compose --project-name $APP_NAME up -d --remove-orphans --wait + - name: Start new stack + run: docker compose --project-name $APP_NAME up -d --wait + + - name: Health check + run: | + echo "Waiting for application to be ready..." + sleep 15 + curl -sf https://${{ vars.APP_DOMAIN }}/api/health || echo "Health check warning - may need more time to start" diff --git a/.gitignore b/.gitignore index 72b2841..20921f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,29 @@ +# Nuxt dev/build outputs +.output +.nuxt +.nitro +.cache +dist + +# Node +node_modules +logs +*.log + +# Environment .env +.env.* +!.env.example + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Claude .claude/settings.local.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee09dbd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/app.config.ts b/app/app.config.ts new file mode 100644 index 0000000..9f027bf --- /dev/null +++ b/app/app.config.ts @@ -0,0 +1,8 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: 'green', + neutral: 'slate' + } + } +}) diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 0000000..5d370a6 --- /dev/null +++ b/app/app.vue @@ -0,0 +1,20 @@ + + + diff --git a/app/assets/css/main.css b/app/assets/css/main.css new file mode 100644 index 0000000..e45c562 --- /dev/null +++ b/app/assets/css/main.css @@ -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; +} diff --git a/app/components/app/AppSidebar.vue b/app/components/app/AppSidebar.vue new file mode 100644 index 0000000..d5507d7 --- /dev/null +++ b/app/components/app/AppSidebar.vue @@ -0,0 +1,59 @@ + diff --git a/app/components/app/ConnectionStatus.vue b/app/components/app/ConnectionStatus.vue new file mode 100644 index 0000000..e410978 --- /dev/null +++ b/app/components/app/ConnectionStatus.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/components/app/UserMenu.vue b/app/components/app/UserMenu.vue new file mode 100644 index 0000000..fa90b17 --- /dev/null +++ b/app/components/app/UserMenu.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/components/common/MetricCard.vue b/app/components/common/MetricCard.vue new file mode 100644 index 0000000..ababe2c --- /dev/null +++ b/app/components/common/MetricCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/components/common/StatusBadge.vue b/app/components/common/StatusBadge.vue new file mode 100644 index 0000000..1eee850 --- /dev/null +++ b/app/components/common/StatusBadge.vue @@ -0,0 +1,42 @@ + + + diff --git a/app/components/instances/CreateInstanceModal.vue b/app/components/instances/CreateInstanceModal.vue new file mode 100644 index 0000000..3cb3ac0 --- /dev/null +++ b/app/components/instances/CreateInstanceModal.vue @@ -0,0 +1,91 @@ + + + diff --git a/app/components/instances/InstanceCard.vue b/app/components/instances/InstanceCard.vue new file mode 100644 index 0000000..4853606 --- /dev/null +++ b/app/components/instances/InstanceCard.vue @@ -0,0 +1,117 @@ + + + diff --git a/app/components/instances/QRCodeModal.vue b/app/components/instances/QRCodeModal.vue new file mode 100644 index 0000000..13a87f1 --- /dev/null +++ b/app/components/instances/QRCodeModal.vue @@ -0,0 +1,111 @@ + + + diff --git a/app/components/messages/ChatItem.vue b/app/components/messages/ChatItem.vue new file mode 100644 index 0000000..b05a8a8 --- /dev/null +++ b/app/components/messages/ChatItem.vue @@ -0,0 +1,68 @@ + + + diff --git a/app/components/messages/MessageBubble.vue b/app/components/messages/MessageBubble.vue new file mode 100644 index 0000000..097d113 --- /dev/null +++ b/app/components/messages/MessageBubble.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/components/messages/MessageInput.vue b/app/components/messages/MessageInput.vue new file mode 100644 index 0000000..223057f --- /dev/null +++ b/app/components/messages/MessageInput.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/components/webhooks/WebhookCard.vue b/app/components/webhooks/WebhookCard.vue new file mode 100644 index 0000000..934cd4c --- /dev/null +++ b/app/components/webhooks/WebhookCard.vue @@ -0,0 +1,107 @@ + + + diff --git a/app/components/webhooks/WebhookFormModal.vue b/app/components/webhooks/WebhookFormModal.vue new file mode 100644 index 0000000..f388854 --- /dev/null +++ b/app/components/webhooks/WebhookFormModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/app/composables/useAuthentik.ts b/app/composables/useAuthentik.ts new file mode 100644 index 0000000..f7fdd1d --- /dev/null +++ b/app/composables/useAuthentik.ts @@ -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', () => { + 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 + } +} diff --git a/app/composables/useInstances.ts b/app/composables/useInstances.ts new file mode 100644 index 0000000..68eada5 --- /dev/null +++ b/app/composables/useInstances.ts @@ -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('instances', () => []) + const loading = useState('instancesLoading', () => false) + const error = useState('instancesError', () => null) + + const fetchInstances = async () => { + loading.value = true + error.value = null + try { + const data = await $fetch('/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 => { + try { + const instance = await $fetch('/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 => { + const result = await $fetch<{ qrCode: string | null }>(`/api/instances/${id}/qr`) + return result.qrCode + } + + const requestPairingCode = async (id: string, phoneNumber: string): Promise => { + 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 + } +} diff --git a/app/composables/useSidebarState.ts b/app/composables/useSidebarState.ts new file mode 100644 index 0000000..5da5a19 --- /dev/null +++ b/app/composables/useSidebarState.ts @@ -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 + } +} diff --git a/app/composables/useToast.ts b/app/composables/useToast.ts new file mode 100644 index 0000000..d13efed --- /dev/null +++ b/app/composables/useToast.ts @@ -0,0 +1,4 @@ +/** + * Re-export useToast from Nuxt UI + */ +export { useToast } from '#imports' diff --git a/app/layouts/dashboard.vue b/app/layouts/dashboard.vue new file mode 100644 index 0000000..b84ab3a --- /dev/null +++ b/app/layouts/dashboard.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue new file mode 100644 index 0000000..f78c071 --- /dev/null +++ b/app/pages/api-docs.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue new file mode 100644 index 0000000..972557e --- /dev/null +++ b/app/pages/index.vue @@ -0,0 +1,113 @@ + + + diff --git a/app/pages/instances/index.vue b/app/pages/instances/index.vue new file mode 100644 index 0000000..96ce344 --- /dev/null +++ b/app/pages/instances/index.vue @@ -0,0 +1,91 @@ + + + diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue new file mode 100644 index 0000000..f02aedb --- /dev/null +++ b/app/pages/messages/index.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/pages/settings.vue b/app/pages/settings.vue new file mode 100644 index 0000000..f940ae6 --- /dev/null +++ b/app/pages/settings.vue @@ -0,0 +1,138 @@ + + + diff --git a/app/pages/webhooks/index.vue b/app/pages/webhooks/index.vue new file mode 100644 index 0000000..44a1459 --- /dev/null +++ b/app/pages/webhooks/index.vue @@ -0,0 +1,86 @@ + + + diff --git a/docker-compose.yml b/docker-compose.yml index c76945a..dc2fb28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,96 +1,75 @@ -version: '3.8' - services: - evolution-postgres: - image: postgres:15-alpine - container_name: evolution-postgres - restart: unless-stopped - environment: - - POSTGRES_USER=evolution - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=evolution - volumes: - - evolution_postgres:/var/lib/postgresql/data - networks: - - principal - healthcheck: - test: ["CMD-SHELL", "pg_isready -U evolution"] - interval: 5s - timeout: 5s - retries: 5 - - evolution-api: - image: atendai/evolution-api:latest - container_name: evolution-api + whatsapp-nucleo: + build: . + image: ${REG}/${REPO_OWNER}/${APP_NAME}:latest + container_name: ${APP_NAME} restart: unless-stopped depends_on: - evolution-postgres: + whatsapp-postgres: condition: service_healthy environment: - # Configuración básica - - SERVER_URL=https://${APP_DOMAIN} - - AUTHENTICATION_TYPE=apikey - - AUTHENTICATION_API_KEY=${EVOLUTION_API_KEY} - - AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES=true - - # Base de datos PostgreSQL - - DATABASE_ENABLED=true - - DATABASE_PROVIDER=postgresql - - DATABASE_CONNECTION_URI=postgresql://evolution:${POSTGRES_PASSWORD}@evolution-postgres:5432/evolution - - DATABASE_SAVE_DATA_INSTANCE=true - - DATABASE_SAVE_DATA_NEW_MESSAGE=true - - DATABASE_SAVE_MESSAGE_UPDATE=true - - DATABASE_SAVE_DATA_CONTACTS=true - - DATABASE_SAVE_DATA_CHATS=true - - DATABASE_SAVE_DATA_LABELS=true - - DATABASE_SAVE_DATA_HISTORIC=true - - # Redis deshabilitado - - CACHE_REDIS_ENABLED=false - - # Webhooks globales (deshabilitado por ahora) - - WEBHOOK_GLOBAL_ENABLED=false - - # Integraciones (deshabilitadas) - - CHATWOOT_ENABLED=false - - TYPEBOT_ENABLED=false - - volumes: - - evolution_instances:/evolution/instances - - evolution_store:/evolution/store + - NODE_ENV=production + - NUXT_HOST=0.0.0.0 + - NUXT_PORT=3000 + - DATABASE_URL=postgresql://whatsapp:${POSTGRES_PASSWORD}@${APP_NAME}-postgres:5432/whatsapp + - NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com + - MASTER_API_KEY=${MASTER_API_KEY} networks: - principal labels: - traefik.enable=true - traefik.docker.network=principal - - traefik.http.services.evolution-api.loadbalancer.server.port=8080 + - traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000 - # Router: Manager UI protegido con Authentik (incluye rutas de callback) - - traefik.http.routers.evolution-ui.rule=Host(`${APP_DOMAIN}`) && (PathPrefix(`/manager`) || PathPrefix(`/outpost.goauthentik.io`)) - - traefik.http.routers.evolution-ui.entrypoints=websecure - - traefik.http.routers.evolution-ui.tls.certresolver=letsencrypt - - traefik.http.routers.evolution-ui.service=evolution-api - - traefik.http.routers.evolution-ui.middlewares=authentik-forward-auth@file,evolution-headers - - traefik.http.routers.evolution-ui.priority=100 + # Router: Recursos publicos (assets, manifest) - SIN autenticacion - ALTA PRIORIDAD + - traefik.http.routers.${APP_NAME}-public.rule=Host(`${APP_DOMAIN}`) && (PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/sw.js`) || PathPrefix(`/_nuxt/`) || PathPrefix(`/icons/`) || PathPrefix(`/favicon.ico`) || PathPrefix(`/api/health`)) + - traefik.http.routers.${APP_NAME}-public.entrypoints=websecure + - traefik.http.routers.${APP_NAME}-public.tls.certresolver=letsencrypt + - traefik.http.routers.${APP_NAME}-public.service=${APP_NAME} + - traefik.http.routers.${APP_NAME}-public.priority=100 + - traefik.http.routers.${APP_NAME}-public.middlewares=${APP_NAME}-headers - # Router: API endpoints (autenticación por API Key, sin Authentik) - - traefik.http.routers.evolution-api.rule=Host(`${APP_DOMAIN}`) && !PathPrefix(`/outpost.goauthentik.io`) && !PathPrefix(`/manager`) - - traefik.http.routers.evolution-api.entrypoints=websecure - - traefik.http.routers.evolution-api.tls.certresolver=letsencrypt - - traefik.http.routers.evolution-api.service=evolution-api - - traefik.http.routers.evolution-api.middlewares=evolution-headers - - traefik.http.routers.evolution-api.priority=10 + # Router: API externa (usa API Key, sin Authentik) - MEDIA PRIORIDAD + - traefik.http.routers.${APP_NAME}-api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api/messages/send`) + - traefik.http.routers.${APP_NAME}-api.entrypoints=websecure + - traefik.http.routers.${APP_NAME}-api.tls.certresolver=letsencrypt + - traefik.http.routers.${APP_NAME}-api.service=${APP_NAME} + - traefik.http.routers.${APP_NAME}-api.priority=50 + - traefik.http.routers.${APP_NAME}-api.middlewares=${APP_NAME}-headers + + # Router: Principal (con Authentik) - BAJA PRIORIDAD + - traefik.http.routers.${APP_NAME}.rule=Host(`${APP_DOMAIN}`) + - traefik.http.routers.${APP_NAME}.entrypoints=websecure + - traefik.http.routers.${APP_NAME}.tls.certresolver=letsencrypt + - traefik.http.routers.${APP_NAME}.service=${APP_NAME} + - traefik.http.routers.${APP_NAME}.priority=10 + - traefik.http.routers.${APP_NAME}.middlewares=authentik-forward-auth@file,${APP_NAME}-headers # Middleware: Headers - - traefik.http.middlewares.evolution-headers.headers.customrequestheaders.X-Forwarded-Proto=https + - traefik.http.middlewares.${APP_NAME}-headers.headers.customrequestheaders.X-Forwarded-Proto=https + + whatsapp-postgres: + image: postgres:16-alpine + container_name: ${APP_NAME}-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=whatsapp + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=whatsapp + volumes: + - whatsapp_postgres:/var/lib/postgresql/data + - ./server/database/init:/docker-entrypoint-initdb.d:ro + networks: + - principal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U whatsapp -d whatsapp"] + interval: 5s + timeout: 5s + retries: 5 volumes: - evolution_instances: - name: evolution_instances - evolution_store: - name: evolution_store - evolution_postgres: - name: evolution_postgres + whatsapp_postgres: + name: ${APP_NAME}_postgres networks: principal: diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..a43e5cb --- /dev/null +++ b/nuxt.config.ts @@ -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' + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..6addb29 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server/api/auth/status.get.ts b/server/api/auth/status.get.ts new file mode 100644 index 0000000..e6eed20 --- /dev/null +++ b/server/api/auth/status.get.ts @@ -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()) : [] + } + } +}) diff --git a/server/api/events/stream.get.ts b/server/api/events/stream.get.ts new file mode 100644 index 0000000..2bab420 --- /dev/null +++ b/server/api/events/stream.get.ts @@ -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(() => {}) +}) diff --git a/server/api/health.get.ts b/server/api/health.get.ts new file mode 100644 index 0000000..92989ea --- /dev/null +++ b/server/api/health.get.ts @@ -0,0 +1,11 @@ +/** + * GET /api/health + * Health check endpoint + */ +export default defineEventHandler(() => { + return { + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0' + } +}) diff --git a/server/api/instances/[id]/connect.post.ts b/server/api/instances/[id]/connect.post.ts new file mode 100644 index 0000000..ef9fe4a --- /dev/null +++ b/server/api/instances/[id]/connect.post.ts @@ -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}` + }) + } +}) diff --git a/server/api/instances/[id]/disconnect.post.ts b/server/api/instances/[id]/disconnect.post.ts new file mode 100644 index 0000000..6c42789 --- /dev/null +++ b/server/api/instances/[id]/disconnect.post.ts @@ -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}` + }) + } +}) diff --git a/server/api/instances/[id]/index.delete.ts b/server/api/instances/[id]/index.delete.ts new file mode 100644 index 0000000..16449bb --- /dev/null +++ b/server/api/instances/[id]/index.delete.ts @@ -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 } +}) diff --git a/server/api/instances/[id]/index.get.ts b/server/api/instances/[id]/index.get.ts new file mode 100644 index 0000000..5e4aed9 --- /dev/null +++ b/server/api/instances/[id]/index.get.ts @@ -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( + `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 + } +}) diff --git a/server/api/instances/[id]/pairing-code.post.ts b/server/api/instances/[id]/pairing-code.post.ts new file mode 100644 index 0000000..aab3a99 --- /dev/null +++ b/server/api/instances/[id]/pairing-code.post.ts @@ -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(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}` + }) + } +}) diff --git a/server/api/instances/[id]/qr.get.ts b/server/api/instances/[id]/qr.get.ts new file mode 100644 index 0000000..8cc52d8 --- /dev/null +++ b/server/api/instances/[id]/qr.get.ts @@ -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 + } +}) diff --git a/server/api/instances/[id]/status.get.ts b/server/api/instances/[id]/status.get.ts new file mode 100644 index 0000000..e85c8a1 --- /dev/null +++ b/server/api/instances/[id]/status.get.ts @@ -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 + } +}) diff --git a/server/api/instances/index.get.ts b/server/api/instances/index.get.ts new file mode 100644 index 0000000..8441cb6 --- /dev/null +++ b/server/api/instances/index.get.ts @@ -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( + `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 + } + }) +}) diff --git a/server/api/instances/index.post.ts b/server/api/instances/index.post.ts new file mode 100644 index 0000000..dafab25 --- /dev/null +++ b/server/api/instances/index.post.ts @@ -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(event) + + if (!body.name?.trim()) { + throw createError({ statusCode: 400, message: 'Name is required' }) + } + + const result = await query( + `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 + } +}) diff --git a/server/api/messages/[instanceId]/[chatId]/index.get.ts b/server/api/messages/[instanceId]/[chatId]/index.get.ts new file mode 100644 index 0000000..d2bf520 --- /dev/null +++ b/server/api/messages/[instanceId]/[chatId]/index.get.ts @@ -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( + `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 + })) +}) diff --git a/server/api/messages/[instanceId]/[chatId]/send.post.ts b/server/api/messages/[instanceId]/[chatId]/send.post.ts new file mode 100644 index 0000000..9e2d56d --- /dev/null +++ b/server/api/messages/[instanceId]/[chatId]/send.post.ts @@ -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(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}` + }) + } +}) diff --git a/server/api/messages/[instanceId]/chats.get.ts b/server/api/messages/[instanceId]/chats.get.ts new file mode 100644 index 0000000..fa912d4 --- /dev/null +++ b/server/api/messages/[instanceId]/chats.get.ts @@ -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( + `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 + })) +}) diff --git a/server/api/messages/send.post.ts b/server/api/messages/send.post.ts new file mode 100644 index 0000000..e32cad1 --- /dev/null +++ b/server/api/messages/send.post.ts @@ -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(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}` + }) + } +}) diff --git a/server/api/webhooks/[id]/index.delete.ts b/server/api/webhooks/[id]/index.delete.ts new file mode 100644 index 0000000..4831e6e --- /dev/null +++ b/server/api/webhooks/[id]/index.delete.ts @@ -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 } +}) diff --git a/server/api/webhooks/[id]/index.put.ts b/server/api/webhooks/[id]/index.put.ts new file mode 100644 index 0000000..045fc2f --- /dev/null +++ b/server/api/webhooks/[id]/index.put.ts @@ -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(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 } +}) diff --git a/server/api/webhooks/[id]/test.post.ts b/server/api/webhooks/[id]/test.post.ts new file mode 100644 index 0000000..4a2ff65 --- /dev/null +++ b/server/api/webhooks/[id]/test.post.ts @@ -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 = { + '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 + } + } +}) diff --git a/server/api/webhooks/index.get.ts b/server/api/webhooks/index.get.ts new file mode 100644 index 0000000..4d39032 --- /dev/null +++ b/server/api/webhooks/index.get.ts @@ -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( + `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 + })) +}) diff --git a/server/api/webhooks/index.post.ts b/server/api/webhooks/index.post.ts new file mode 100644 index 0000000..e83bd10 --- /dev/null +++ b/server/api/webhooks/index.post.ts @@ -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(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 + } +}) diff --git a/server/database/init/001_schema.sql b/server/database/init/001_schema.sql new file mode 100644 index 0000000..1e27cf9 --- /dev/null +++ b/server/database/init/001_schema.sql @@ -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(); diff --git a/server/plugins/baileys.ts b/server/plugins/baileys.ts new file mode 100644 index 0000000..c2e881b --- /dev/null +++ b/server/plugins/baileys.ts @@ -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) + } +}) diff --git a/server/services/baileys/auth-state.ts b/server/services/baileys/auth-state.ts new file mode 100644 index 0000000..a73b437 --- /dev/null +++ b/server/services/baileys/auth-state.ts @@ -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: (type: T, ids: string[]) => Promise<{ [id: string]: SignalDataTypeMap[T] }> + set: (data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }) => Promise + } + } + saveCreds: () => Promise +} + +export async function usePostgresAuthState(instanceId: string): Promise { + // Load or create credentials + const loadCreds = async (): Promise => { + 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 => { + 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 ( + 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 => { + 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 { + await query( + 'DELETE FROM auth_keys WHERE instance_id = $1', + [instanceId] + ) +} diff --git a/server/services/baileys/manager.ts b/server/services/baileys/manager.ts new file mode 100644 index 0000000..67a36f6 --- /dev/null +++ b/server/services/baileys/manager.ts @@ -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 = 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 { + 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 { + // 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, + 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 { + 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 { + 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 = {} + ): Promise { + 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 { + 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() diff --git a/server/services/webhooks/dispatcher.ts b/server/services/webhooks/dispatcher.ts new file mode 100644 index 0000000..0cd5a88 --- /dev/null +++ b/server/services/webhooks/dispatcher.ts @@ -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 + 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( + `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 = { + '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() diff --git a/server/utils/database.ts b/server/utils/database.ts new file mode 100644 index 0000000..b345bcc --- /dev/null +++ b/server/utils/database.ts @@ -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( + text: string, + params?: any[] +): Promise> { + const client = await getPool().connect() + try { + return await client.query(text, params) + } finally { + client.release() + } +} + +export async function transaction( + callback: (client: pg.PoolClient) => Promise +): Promise { + 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() + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4b34df1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +}