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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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