feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Reemplazo completo de Evolution API por implementación directa con Baileys. Características: - Dashboard completo con Nuxt UI v4 - Soporte para múltiples instancias de WhatsApp - Conexión via QR code o pairing code - Persistencia de mensajes en PostgreSQL - API REST para integraciones externas - Webhooks con firma HMAC - SSE para actualizaciones en tiempo real - Autenticación con Authentik
This commit is contained in:
29
server/api/auth/status.get.ts
Normal file
29
server/api/auth/status.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Verifica el estado de autenticacion del usuario via Authentik headers
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const headers = getHeaders(event)
|
||||
|
||||
const username = headers['x-authentik-username']
|
||||
const email = headers['x-authentik-email']
|
||||
const name = headers['x-authentik-name']
|
||||
const groups = headers['x-authentik-groups']
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
authenticated: false,
|
||||
user: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
groups: groups ? groups.split('|').filter((g: string) => g.trim()) : []
|
||||
}
|
||||
}
|
||||
})
|
||||
66
server/api/events/stream.get.ts
Normal file
66
server/api/events/stream.get.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* GET /api/events/stream
|
||||
* Server-Sent Events endpoint for real-time updates
|
||||
*/
|
||||
import { baileysManager } from '../../services/baileys/manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
setHeader(event, 'Content-Type', 'text/event-stream')
|
||||
setHeader(event, 'Cache-Control', 'no-cache')
|
||||
setHeader(event, 'Connection', 'keep-alive')
|
||||
|
||||
// Get the raw response
|
||||
const res = event.node.res
|
||||
|
||||
// Send initial connection message
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`)
|
||||
|
||||
// Event handlers
|
||||
const handlers = {
|
||||
'instance.status': (data: any) => {
|
||||
res.write(`event: instance.status\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
},
|
||||
'instance.qr': (data: any) => {
|
||||
res.write(`event: instance.qr\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
},
|
||||
'instance.pairing': (data: any) => {
|
||||
res.write(`event: instance.pairing\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
},
|
||||
'message.received': (data: any) => {
|
||||
res.write(`event: message.received\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
},
|
||||
'message.sent': (data: any) => {
|
||||
res.write(`event: message.sent\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
},
|
||||
'message.status': (data: any) => {
|
||||
res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Register listeners
|
||||
for (const [eventName, handler] of Object.entries(handlers)) {
|
||||
baileysManager.on(eventName, handler)
|
||||
}
|
||||
|
||||
// Keep-alive ping
|
||||
const pingInterval = setInterval(() => {
|
||||
res.write(`: ping\n\n`)
|
||||
}, 30000)
|
||||
|
||||
// Cleanup on close
|
||||
event.node.req.on('close', () => {
|
||||
clearInterval(pingInterval)
|
||||
for (const [eventName, handler] of Object.entries(handlers)) {
|
||||
baileysManager.off(eventName, handler)
|
||||
}
|
||||
})
|
||||
|
||||
// Don't close the connection
|
||||
return new Promise(() => {})
|
||||
})
|
||||
11
server/api/health.get.ts
Normal file
11
server/api/health.get.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint
|
||||
*/
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
}
|
||||
})
|
||||
54
server/api/instances/[id]/connect.post.ts
Normal file
54
server/api/instances/[id]/connect.post.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/instances/:id/connect
|
||||
* Connect an instance (generates QR code)
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string; status: string }>(
|
||||
'SELECT id, status FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
const instance = result.rows[0]
|
||||
|
||||
// Don't connect if already connected
|
||||
if (instance.status === 'connected') {
|
||||
throw createError({ statusCode: 400, message: 'Instance already connected' })
|
||||
}
|
||||
|
||||
// Start connection
|
||||
try {
|
||||
await baileysManager.connect(id!)
|
||||
|
||||
// Wait a bit for QR to generate
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const qrCode = baileysManager.getQRCode(id!)
|
||||
const status = baileysManager.getStatus(id!)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: status?.status || 'connecting',
|
||||
qrCode
|
||||
}
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Failed to connect: ${(error as Error).message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
35
server/api/instances/[id]/disconnect.post.ts
Normal file
35
server/api/instances/[id]/disconnect.post.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/instances/:id/disconnect
|
||||
* Disconnect an instance
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string }>(
|
||||
'SELECT id FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
try {
|
||||
await baileysManager.disconnect(id!)
|
||||
return { success: true, status: 'disconnected' }
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Failed to disconnect: ${(error as Error).message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
41
server/api/instances/[id]/index.delete.ts
Normal file
41
server/api/instances/[id]/index.delete.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* DELETE /api/instances/:id
|
||||
* Delete an instance
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
import { clearAuthState } from '../../../services/baileys/auth-state'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string }>(
|
||||
'SELECT id FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
try {
|
||||
await baileysManager.disconnect(id!)
|
||||
} catch (error) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
|
||||
// Clear auth state
|
||||
await clearAuthState(id!)
|
||||
|
||||
// Delete from database (cascades to related tables)
|
||||
await query('DELETE FROM instances WHERE id = $1', [id])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
53
server/api/instances/[id]/index.get.ts
Normal file
53
server/api/instances/[id]/index.get.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GET /api/instances/:id
|
||||
* Get instance details
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
interface InstanceRow {
|
||||
id: string
|
||||
name: string
|
||||
phone_number: string | null
|
||||
status: string
|
||||
qr_code: string | null
|
||||
pairing_code: string | null
|
||||
last_connected_at: Date | null
|
||||
created_by: string
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
const result = await query<InstanceRow>(
|
||||
`SELECT id, name, phone_number, status, qr_code, pairing_code,
|
||||
last_connected_at, created_by, created_at
|
||||
FROM instances WHERE id = $1`,
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
const instance = result.rows[0]
|
||||
const liveStatus = baileysManager.getStatus(instance.id)
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
phoneNumber: instance.phone_number,
|
||||
status: liveStatus?.status || instance.status,
|
||||
qrCode: liveStatus?.qrCode || instance.qr_code,
|
||||
pairingCode: liveStatus?.pairingCode || instance.pairing_code,
|
||||
lastConnectedAt: instance.last_connected_at,
|
||||
createdBy: instance.created_by,
|
||||
createdAt: instance.created_at
|
||||
}
|
||||
})
|
||||
69
server/api/instances/[id]/pairing-code.post.ts
Normal file
69
server/api/instances/[id]/pairing-code.post.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /api/instances/:id/pairing-code
|
||||
* Request a pairing code for connection without QR
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
interface PairingCodeBody {
|
||||
phoneNumber: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody<PairingCodeBody>(event)
|
||||
|
||||
if (!body.phoneNumber?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'Phone number is required' })
|
||||
}
|
||||
|
||||
// Clean phone number (remove +, spaces, dashes)
|
||||
const cleanPhone = body.phoneNumber.replace(/[^0-9]/g, '')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string; status: string }>(
|
||||
'SELECT id, status FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
const instance = result.rows[0]
|
||||
|
||||
if (instance.status === 'connected') {
|
||||
throw createError({ statusCode: 400, message: 'Instance already connected' })
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect with pairing code mode
|
||||
await baileysManager.connect(id!, true, cleanPhone)
|
||||
|
||||
// Wait for pairing code
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const pairingCode = baileysManager.getPairingCode(id!)
|
||||
const status = baileysManager.getStatus(id!)
|
||||
|
||||
if (!pairingCode) {
|
||||
throw new Error('Failed to generate pairing code')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: pairingCode,
|
||||
status: status?.status || 'pairing'
|
||||
}
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Failed to request pairing code: ${(error as Error).message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
34
server/api/instances/[id]/qr.get.ts
Normal file
34
server/api/instances/[id]/qr.get.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* GET /api/instances/:id/qr
|
||||
* Get current QR code for an instance
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string; qr_code: string | null; status: string }>(
|
||||
'SELECT id, qr_code, status FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
// Get live QR from manager
|
||||
const qrCode = baileysManager.getQRCode(id!)
|
||||
const status = baileysManager.getStatus(id!)
|
||||
|
||||
return {
|
||||
qrCode: qrCode || result.rows[0].qr_code,
|
||||
status: status?.status || result.rows[0].status
|
||||
}
|
||||
})
|
||||
36
server/api/instances/[id]/status.get.ts
Normal file
36
server/api/instances/[id]/status.get.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* GET /api/instances/:id/status
|
||||
* Get instance connection status
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
import { baileysManager } from '../../../services/baileys/manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Check if instance exists
|
||||
const result = await query<{ id: string; status: string; phone_number: string | null }>(
|
||||
'SELECT id, status, phone_number FROM instances WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
const instance = result.rows[0]
|
||||
const liveStatus = baileysManager.getStatus(id!)
|
||||
|
||||
return {
|
||||
instanceId: id,
|
||||
status: liveStatus?.status || instance.status,
|
||||
phoneNumber: liveStatus?.phoneNumber || instance.phone_number,
|
||||
hasQR: !!liveStatus?.qrCode,
|
||||
hasPairingCode: !!liveStatus?.pairingCode
|
||||
}
|
||||
})
|
||||
42
server/api/instances/index.get.ts
Normal file
42
server/api/instances/index.get.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/instances
|
||||
* List all instances
|
||||
*/
|
||||
import { query } from '../../utils/database'
|
||||
import { baileysManager } from '../../services/baileys/manager'
|
||||
|
||||
interface InstanceRow {
|
||||
id: string
|
||||
name: string
|
||||
phone_number: string | null
|
||||
status: string
|
||||
last_connected_at: Date | null
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Check auth
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const result = await query<InstanceRow>(
|
||||
`SELECT id, name, phone_number, status, last_connected_at, created_at
|
||||
FROM instances
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
|
||||
// Enrich with live status from manager
|
||||
return result.rows.map(row => {
|
||||
const liveStatus = baileysManager.getStatus(row.id)
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
phoneNumber: row.phone_number,
|
||||
status: liveStatus?.status || row.status,
|
||||
lastConnectedAt: row.last_connected_at,
|
||||
createdAt: row.created_at
|
||||
}
|
||||
})
|
||||
})
|
||||
48
server/api/instances/index.post.ts
Normal file
48
server/api/instances/index.post.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* POST /api/instances
|
||||
* Create a new instance
|
||||
*/
|
||||
import { query } from '../../utils/database'
|
||||
|
||||
interface CreateInstanceBody {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
id: string
|
||||
name: string
|
||||
phone_number: string | null
|
||||
status: string
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Check auth
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const body = await readBody<CreateInstanceBody>(event)
|
||||
|
||||
if (!body.name?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'Name is required' })
|
||||
}
|
||||
|
||||
const result = await query<InstanceRow>(
|
||||
`INSERT INTO instances (name, created_by)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, phone_number, status, created_at`,
|
||||
[body.name.trim(), username]
|
||||
)
|
||||
|
||||
const instance = result.rows[0]
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
phoneNumber: instance.phone_number,
|
||||
status: instance.status,
|
||||
createdAt: instance.created_at
|
||||
}
|
||||
})
|
||||
72
server/api/messages/[instanceId]/[chatId]/index.get.ts
Normal file
72
server/api/messages/[instanceId]/[chatId]/index.get.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GET /api/messages/:instanceId/:chatId
|
||||
* Get messages for a chat
|
||||
*/
|
||||
import { query } from '../../../../utils/database'
|
||||
|
||||
interface MessageRow {
|
||||
id: string
|
||||
message_id: string
|
||||
from_jid: string
|
||||
from_me: boolean
|
||||
message_type: string
|
||||
content: string | null
|
||||
caption: string | null
|
||||
media_url: string | null
|
||||
timestamp: Date
|
||||
status: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const instanceId = getRouterParam(event, 'instanceId')
|
||||
const chatId = getRouterParam(event, 'chatId')
|
||||
|
||||
// Get query params for pagination
|
||||
const queryParams = getQuery(event)
|
||||
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
|
||||
const offset = parseInt(queryParams.offset as string) || 0
|
||||
|
||||
// Verify chat exists and belongs to instance
|
||||
const chatCheck = await query(
|
||||
'SELECT id FROM chats WHERE id = $1 AND instance_id = $2',
|
||||
[chatId, instanceId]
|
||||
)
|
||||
if (chatCheck.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Chat not found' })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const result = await query<MessageRow>(
|
||||
`SELECT id, message_id, from_jid, from_me, message_type,
|
||||
content, caption, media_url, timestamp, status
|
||||
FROM messages
|
||||
WHERE chat_id = $1
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[chatId, limit, offset]
|
||||
)
|
||||
|
||||
// Mark as read
|
||||
await query(
|
||||
'UPDATE chats SET unread_count = 0 WHERE id = $1',
|
||||
[chatId]
|
||||
)
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
messageId: row.message_id,
|
||||
fromJid: row.from_jid,
|
||||
fromMe: row.from_me,
|
||||
type: row.message_type,
|
||||
content: row.content,
|
||||
caption: row.caption,
|
||||
mediaUrl: row.media_url,
|
||||
timestamp: row.timestamp,
|
||||
status: row.status
|
||||
}))
|
||||
})
|
||||
53
server/api/messages/[instanceId]/[chatId]/send.post.ts
Normal file
53
server/api/messages/[instanceId]/[chatId]/send.post.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/messages/:instanceId/:chatId/send
|
||||
* Send a message to a chat (from UI)
|
||||
*/
|
||||
import { query } from '../../../../utils/database'
|
||||
import { baileysManager } from '../../../../services/baileys/manager'
|
||||
|
||||
interface SendMessageBody {
|
||||
message: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const instanceId = getRouterParam(event, 'instanceId')
|
||||
const chatId = getRouterParam(event, 'chatId')
|
||||
const body = await readBody<SendMessageBody>(event)
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'Message is required' })
|
||||
}
|
||||
|
||||
// Get chat JID
|
||||
const chatResult = await query<{ jid: string }>(
|
||||
'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2',
|
||||
[chatId, instanceId]
|
||||
)
|
||||
|
||||
if (chatResult.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Chat not found' })
|
||||
}
|
||||
|
||||
const jid = chatResult.rows[0].jid
|
||||
|
||||
try {
|
||||
const result = await baileysManager.sendMessage(instanceId!, jid, {
|
||||
text: body.message
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.key.id
|
||||
}
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Failed to send message: ${(error as Error).message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
47
server/api/messages/[instanceId]/chats.get.ts
Normal file
47
server/api/messages/[instanceId]/chats.get.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/messages/:instanceId/chats
|
||||
* Get all chats for an instance
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
|
||||
interface ChatRow {
|
||||
id: string
|
||||
jid: string
|
||||
name: string | null
|
||||
is_group: boolean
|
||||
unread_count: number
|
||||
last_message_at: Date | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const instanceId = getRouterParam(event, 'instanceId')
|
||||
|
||||
// Verify instance exists
|
||||
const instanceCheck = await query('SELECT id FROM instances WHERE id = $1', [instanceId])
|
||||
if (instanceCheck.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Instance not found' })
|
||||
}
|
||||
|
||||
// Get chats with last message
|
||||
const result = await query<ChatRow>(
|
||||
`SELECT c.id, c.jid, c.name, c.is_group, c.unread_count, c.last_message_at
|
||||
FROM chats c
|
||||
WHERE c.instance_id = $1
|
||||
ORDER BY c.last_message_at DESC NULLS LAST`,
|
||||
[instanceId]
|
||||
)
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
jid: row.jid,
|
||||
name: row.name || row.jid.split('@')[0],
|
||||
isGroup: row.is_group,
|
||||
unreadCount: row.unread_count,
|
||||
lastMessageAt: row.last_message_at
|
||||
}))
|
||||
})
|
||||
92
server/api/messages/send.post.ts
Normal file
92
server/api/messages/send.post.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* POST /api/messages/send
|
||||
* Send a message (External API - uses API Key)
|
||||
*/
|
||||
import { query } from '../../utils/database'
|
||||
import { baileysManager } from '../../services/baileys/manager'
|
||||
|
||||
interface SendMessageBody {
|
||||
instanceId: string
|
||||
to: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Check API Key authentication
|
||||
const authHeader = getHeader(event, 'authorization')
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw createError({ statusCode: 401, message: 'API Key required' })
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7)
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Check master API key
|
||||
if (config.masterApiKey && apiKey === config.masterApiKey) {
|
||||
// Master key - allowed
|
||||
} else {
|
||||
// Check database API keys
|
||||
const crypto = await import('crypto')
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
|
||||
|
||||
const keyResult = await query(
|
||||
`SELECT id, instance_id FROM api_keys
|
||||
WHERE key_hash = $1 AND is_active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[keyHash]
|
||||
)
|
||||
|
||||
if (keyResult.rows.length === 0) {
|
||||
throw createError({ statusCode: 401, message: 'Invalid API Key' })
|
||||
}
|
||||
|
||||
// Update last used
|
||||
await query(
|
||||
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
|
||||
[keyResult.rows[0].id]
|
||||
)
|
||||
}
|
||||
|
||||
// Parse body
|
||||
const body = await readBody<SendMessageBody>(event)
|
||||
|
||||
if (!body.instanceId) {
|
||||
throw createError({ statusCode: 400, message: 'instanceId is required' })
|
||||
}
|
||||
if (!body.to) {
|
||||
throw createError({ statusCode: 400, message: 'to is required' })
|
||||
}
|
||||
if (!body.message?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'message is required' })
|
||||
}
|
||||
|
||||
// Format JID
|
||||
let jid = body.to.replace(/[^0-9]/g, '')
|
||||
if (!jid.includes('@')) {
|
||||
jid = `${jid}@s.whatsapp.net`
|
||||
}
|
||||
|
||||
// Check instance exists and is connected
|
||||
const status = baileysManager.getStatus(body.instanceId)
|
||||
if (!status || status.status !== 'connected') {
|
||||
throw createError({ statusCode: 400, message: 'Instance not connected' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await baileysManager.sendMessage(body.instanceId, jid, {
|
||||
text: body.message
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.key.id,
|
||||
to: jid
|
||||
}
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `Failed to send message: ${(error as Error).message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
22
server/api/webhooks/[id]/index.delete.ts
Normal file
22
server/api/webhooks/[id]/index.delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DELETE /api/webhooks/:id
|
||||
* Delete a webhook
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
const result = await query('DELETE FROM webhooks WHERE id = $1 RETURNING id', [id])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Webhook not found' })
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
72
server/api/webhooks/[id]/index.put.ts
Normal file
72
server/api/webhooks/[id]/index.put.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* PUT /api/webhooks/:id
|
||||
* Update a webhook
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
|
||||
interface UpdateWebhookBody {
|
||||
name?: string
|
||||
url?: string
|
||||
secret?: string
|
||||
events?: string[]
|
||||
instanceId?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateWebhookBody>(event)
|
||||
|
||||
// Check if webhook exists
|
||||
const existing = await query('SELECT id FROM webhooks WHERE id = $1', [id])
|
||||
if (existing.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Webhook not found' })
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`)
|
||||
values.push(body.name)
|
||||
}
|
||||
if (body.url !== undefined) {
|
||||
updates.push(`url = $${paramIndex++}`)
|
||||
values.push(body.url)
|
||||
}
|
||||
if (body.secret !== undefined) {
|
||||
updates.push(`secret = $${paramIndex++}`)
|
||||
values.push(body.secret || null)
|
||||
}
|
||||
if (body.events !== undefined) {
|
||||
updates.push(`events = $${paramIndex++}`)
|
||||
values.push(body.events)
|
||||
}
|
||||
if (body.instanceId !== undefined) {
|
||||
updates.push(`instance_id = $${paramIndex++}`)
|
||||
values.push(body.instanceId || null)
|
||||
}
|
||||
if (body.isActive !== undefined) {
|
||||
updates.push(`is_active = $${paramIndex++}`)
|
||||
values.push(body.isActive)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw createError({ statusCode: 400, message: 'No fields to update' })
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
await query(
|
||||
`UPDATE webhooks SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex}`,
|
||||
values
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
100
server/api/webhooks/[id]/test.post.ts
Normal file
100
server/api/webhooks/[id]/test.post.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /api/webhooks/:id/test
|
||||
* Test a webhook with a sample payload
|
||||
*/
|
||||
import { query } from '../../../utils/database'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
// Get webhook
|
||||
const result = await query<{ url: string; secret: string | null; headers: any }>(
|
||||
'SELECT url, secret, headers FROM webhooks WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createError({ statusCode: 404, message: 'Webhook not found' })
|
||||
}
|
||||
|
||||
const webhook = result.rows[0]
|
||||
|
||||
// Create test payload
|
||||
const testPayload = {
|
||||
event: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: 'This is a test webhook delivery',
|
||||
webhookId: id,
|
||||
sentBy: username
|
||||
}
|
||||
}
|
||||
|
||||
const body = JSON.stringify(testPayload)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Event': 'test',
|
||||
'X-Webhook-Timestamp': Date.now().toString(),
|
||||
...(webhook.headers || {})
|
||||
}
|
||||
|
||||
// Add signature if secret exists
|
||||
if (webhook.secret) {
|
||||
const crypto = await import('crypto')
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
// Log the test
|
||||
await query(
|
||||
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, response_body, attempt, delivered_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[id, 'test', testPayload, response.status, responseText.slice(0, 1000), 1, new Date()]
|
||||
)
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: responseText.slice(0, 500)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
|
||||
// Log the failure
|
||||
await query(
|
||||
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message, attempt)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[id, 'test', testPayload, 0, errorMessage, 1]
|
||||
)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
}
|
||||
}
|
||||
})
|
||||
40
server/api/webhooks/index.get.ts
Normal file
40
server/api/webhooks/index.get.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* GET /api/webhooks
|
||||
* List all webhooks
|
||||
*/
|
||||
import { query } from '../../utils/database'
|
||||
|
||||
interface WebhookRow {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
events: string[]
|
||||
is_active: boolean
|
||||
instance_id: string | null
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const result = await query<WebhookRow>(
|
||||
`SELECT w.id, w.name, w.url, w.events, w.is_active, w.instance_id, w.created_at,
|
||||
i.name as instance_name
|
||||
FROM webhooks w
|
||||
LEFT JOIN instances i ON w.instance_id = i.id
|
||||
ORDER BY w.created_at DESC`
|
||||
)
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
events: row.events,
|
||||
isActive: row.is_active,
|
||||
instanceId: row.instance_id,
|
||||
createdAt: row.created_at
|
||||
}))
|
||||
})
|
||||
54
server/api/webhooks/index.post.ts
Normal file
54
server/api/webhooks/index.post.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/webhooks
|
||||
* Create a webhook
|
||||
*/
|
||||
import { query } from '../../utils/database'
|
||||
|
||||
interface CreateWebhookBody {
|
||||
name: string
|
||||
url: string
|
||||
secret?: string
|
||||
events: string[]
|
||||
instanceId?: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const username = getHeader(event, 'x-authentik-username')
|
||||
if (!username) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const body = await readBody<CreateWebhookBody>(event)
|
||||
|
||||
if (!body.name?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'Name is required' })
|
||||
}
|
||||
if (!body.url?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'URL is required' })
|
||||
}
|
||||
if (!body.events?.length) {
|
||||
throw createError({ statusCode: 400, message: 'At least one event is required' })
|
||||
}
|
||||
|
||||
const result = await query<{ id: string }>(
|
||||
`INSERT INTO webhooks (name, url, secret, events, instance_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
body.name.trim(),
|
||||
body.url.trim(),
|
||||
body.secret || null,
|
||||
body.events,
|
||||
body.instanceId || null,
|
||||
username
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
id: result.rows[0].id,
|
||||
name: body.name,
|
||||
url: body.url,
|
||||
events: body.events,
|
||||
instanceId: body.instanceId || null
|
||||
}
|
||||
})
|
||||
208
server/database/init/001_schema.sql
Normal file
208
server/database/init/001_schema.sql
Normal file
@@ -0,0 +1,208 @@
|
||||
-- =====================================================
|
||||
-- WhatsApp Nucleo - Database Schema
|
||||
-- =====================================================
|
||||
|
||||
-- Extension for UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- =====================================================
|
||||
-- INSTANCES: WhatsApp connection instances
|
||||
-- =====================================================
|
||||
CREATE TABLE instances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
phone_number VARCHAR(20),
|
||||
status VARCHAR(20) DEFAULT 'disconnected'
|
||||
CHECK (status IN ('disconnected', 'connecting', 'connected', 'qr_ready', 'pairing')),
|
||||
qr_code TEXT,
|
||||
pairing_code VARCHAR(10),
|
||||
last_connected_at TIMESTAMPTZ,
|
||||
created_by VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_phone UNIQUE (phone_number)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- AUTH_KEYS: Baileys credentials per instance
|
||||
-- =====================================================
|
||||
CREATE TABLE auth_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
key_type VARCHAR(50) NOT NULL,
|
||||
key_id VARCHAR(100),
|
||||
key_data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_key UNIQUE (instance_id, key_type, key_id)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- CONTACTS: WhatsApp contacts
|
||||
-- =====================================================
|
||||
CREATE TABLE contacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
jid VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255),
|
||||
push_name VARCHAR(255),
|
||||
phone_number VARCHAR(20),
|
||||
profile_picture_url TEXT,
|
||||
is_group BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_contact UNIQUE (instance_id, jid)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- CHATS: Conversations
|
||||
-- =====================================================
|
||||
CREATE TABLE chats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
jid VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255),
|
||||
is_group BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
last_message_id UUID,
|
||||
last_message_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_chat UNIQUE (instance_id, jid)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- MESSAGES: All messages
|
||||
-- =====================================================
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
message_id VARCHAR(100) NOT NULL,
|
||||
from_jid VARCHAR(100) NOT NULL,
|
||||
to_jid VARCHAR(100),
|
||||
from_me BOOLEAN DEFAULT FALSE,
|
||||
message_type VARCHAR(50) NOT NULL,
|
||||
content TEXT,
|
||||
caption TEXT,
|
||||
media_url TEXT,
|
||||
media_mimetype VARCHAR(100),
|
||||
media_filename VARCHAR(255),
|
||||
quoted_message_id VARCHAR(100),
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'sent'
|
||||
CHECK (status IN ('pending', 'sent', 'delivered', 'read', 'failed')),
|
||||
raw_message JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_message UNIQUE (instance_id, message_id)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- WEBHOOKS: Webhook configurations
|
||||
-- =====================================================
|
||||
CREATE TABLE webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID REFERENCES instances(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret VARCHAR(255),
|
||||
events TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
headers JSONB DEFAULT '{}',
|
||||
retry_count INTEGER DEFAULT 3,
|
||||
timeout_ms INTEGER DEFAULT 5000,
|
||||
created_by VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- WEBHOOK_LOGS: Delivery logs
|
||||
-- =====================================================
|
||||
CREATE TABLE webhook_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
response_status INTEGER,
|
||||
response_body TEXT,
|
||||
error_message TEXT,
|
||||
attempt INTEGER DEFAULT 1,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- API_KEYS: External API access keys
|
||||
-- =====================================================
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
key_prefix VARCHAR(10) NOT NULL,
|
||||
instance_id UUID REFERENCES instances(id) ON DELETE CASCADE,
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_by VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- INDEXES for performance
|
||||
-- =====================================================
|
||||
CREATE INDEX idx_auth_keys_instance ON auth_keys(instance_id);
|
||||
CREATE INDEX idx_contacts_instance ON contacts(instance_id);
|
||||
CREATE INDEX idx_chats_instance ON chats(instance_id);
|
||||
CREATE INDEX idx_chats_last_message ON chats(instance_id, last_message_at DESC);
|
||||
CREATE INDEX idx_messages_instance_chat ON messages(instance_id, chat_id);
|
||||
CREATE INDEX idx_messages_timestamp ON messages(timestamp DESC);
|
||||
CREATE INDEX idx_messages_chat_timestamp ON messages(chat_id, timestamp DESC);
|
||||
CREATE INDEX idx_webhooks_instance ON webhooks(instance_id);
|
||||
CREATE INDEX idx_webhooks_active ON webhooks(is_active) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_webhook_logs_webhook ON webhook_logs(webhook_id, created_at DESC);
|
||||
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix) WHERE is_active = TRUE;
|
||||
|
||||
-- =====================================================
|
||||
-- TRIGGERS for updated_at
|
||||
-- =====================================================
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_instances_updated_at
|
||||
BEFORE UPDATE ON instances
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_auth_keys_updated_at
|
||||
BEFORE UPDATE ON auth_keys
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_contacts_updated_at
|
||||
BEFORE UPDATE ON contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_chats_updated_at
|
||||
BEFORE UPDATE ON chats
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_webhooks_updated_at
|
||||
BEFORE UPDATE ON webhooks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
18
server/plugins/baileys.ts
Normal file
18
server/plugins/baileys.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Nitro plugin to initialize Baileys manager on server start
|
||||
*/
|
||||
import { baileysManager } from '../services/baileys/manager'
|
||||
|
||||
export default defineNitroPlugin(async () => {
|
||||
console.log('[Plugin] Initializing Baileys Manager...')
|
||||
|
||||
// Small delay to ensure database is ready
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
try {
|
||||
await baileysManager.initialize()
|
||||
console.log('[Plugin] Baileys Manager initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('[Plugin] Failed to initialize Baileys Manager:', error)
|
||||
}
|
||||
})
|
||||
131
server/services/baileys/auth-state.ts
Normal file
131
server/services/baileys/auth-state.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* PostgreSQL-based auth state for Baileys
|
||||
* Stores credentials and keys in the database instead of files
|
||||
*/
|
||||
import type { AuthenticationCreds, SignalDataTypeMap } from '@whiskeysockets/baileys'
|
||||
import { initAuthCreds, BufferJSON, proto } from '@whiskeysockets/baileys'
|
||||
import { query } from '../../utils/database'
|
||||
|
||||
export interface PostgresAuthState {
|
||||
state: {
|
||||
creds: AuthenticationCreds
|
||||
keys: {
|
||||
get: <T extends keyof SignalDataTypeMap>(type: T, ids: string[]) => Promise<{ [id: string]: SignalDataTypeMap[T] }>
|
||||
set: (data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }) => Promise<void>
|
||||
}
|
||||
}
|
||||
saveCreds: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function usePostgresAuthState(instanceId: string): Promise<PostgresAuthState> {
|
||||
// Load or create credentials
|
||||
const loadCreds = async (): Promise<AuthenticationCreds> => {
|
||||
const result = await query<{ key_data: any }>(
|
||||
'SELECT key_data FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
|
||||
[instanceId, 'creds', 'default']
|
||||
)
|
||||
|
||||
if (result.rows.length > 0 && result.rows[0].key_data) {
|
||||
return JSON.parse(JSON.stringify(result.rows[0].key_data), BufferJSON.reviver)
|
||||
}
|
||||
|
||||
return initAuthCreds()
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
const saveCreds = async (creds: AuthenticationCreds): Promise<void> => {
|
||||
const data = JSON.parse(JSON.stringify(creds, BufferJSON.replacer))
|
||||
await query(
|
||||
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (instance_id, key_type, key_id)
|
||||
DO UPDATE SET key_data = $4, updated_at = NOW()`,
|
||||
[instanceId, 'creds', 'default', data]
|
||||
)
|
||||
}
|
||||
|
||||
// Load keys by type and ids
|
||||
const loadKeys = async <T extends keyof SignalDataTypeMap>(
|
||||
type: T,
|
||||
ids: string[]
|
||||
): Promise<{ [id: string]: SignalDataTypeMap[T] }> => {
|
||||
const result: { [id: string]: SignalDataTypeMap[T] } = {}
|
||||
|
||||
if (ids.length === 0) return result
|
||||
|
||||
const placeholders = ids.map((_, i) => `$${i + 3}`).join(', ')
|
||||
const queryResult = await query<{ key_id: string; key_data: any }>(
|
||||
`SELECT key_id, key_data FROM auth_keys
|
||||
WHERE instance_id = $1 AND key_type = $2 AND key_id IN (${placeholders})`,
|
||||
[instanceId, type, ...ids]
|
||||
)
|
||||
|
||||
for (const row of queryResult.rows) {
|
||||
let value = JSON.parse(JSON.stringify(row.key_data), BufferJSON.reviver)
|
||||
|
||||
// Handle special types
|
||||
if (type === 'app-state-sync-key' && value) {
|
||||
value = proto.Message.AppStateSyncKeyData.fromObject(value)
|
||||
}
|
||||
|
||||
result[row.key_id] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Save keys
|
||||
const saveKeys = async (
|
||||
data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }
|
||||
): Promise<void> => {
|
||||
for (const type in data) {
|
||||
for (const id in data[type]) {
|
||||
const value = data[type][id]
|
||||
|
||||
if (value === null) {
|
||||
// Delete key
|
||||
await query(
|
||||
'DELETE FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
|
||||
[instanceId, type, id]
|
||||
)
|
||||
} else {
|
||||
// Upsert key
|
||||
const serialized = JSON.parse(JSON.stringify(value, BufferJSON.replacer))
|
||||
await query(
|
||||
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (instance_id, key_type, key_id)
|
||||
DO UPDATE SET key_data = $4, updated_at = NOW()`,
|
||||
[instanceId, type, id, serialized]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial credentials
|
||||
const creds = await loadCreds()
|
||||
|
||||
return {
|
||||
state: {
|
||||
creds,
|
||||
keys: {
|
||||
get: loadKeys,
|
||||
set: saveKeys
|
||||
}
|
||||
},
|
||||
saveCreds: async () => {
|
||||
await saveCreds(creds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all auth data for an instance
|
||||
*/
|
||||
export async function clearAuthState(instanceId: string): Promise<void> {
|
||||
await query(
|
||||
'DELETE FROM auth_keys WHERE instance_id = $1',
|
||||
[instanceId]
|
||||
)
|
||||
}
|
||||
456
server/services/baileys/manager.ts
Normal file
456
server/services/baileys/manager.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* BaileysManager - Manages multiple WhatsApp instances
|
||||
* Singleton pattern for managing all Baileys connections
|
||||
*/
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
useMultiFileAuthState,
|
||||
type WASocket,
|
||||
type BaileysEventMap,
|
||||
Browsers
|
||||
} from '@whiskeysockets/baileys'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { EventEmitter } from 'events'
|
||||
import QRCode from 'qrcode'
|
||||
import pino from 'pino'
|
||||
import { usePostgresAuthState, clearAuthState } from './auth-state'
|
||||
import { query } from '../../utils/database'
|
||||
|
||||
// Types
|
||||
export interface ManagedInstance {
|
||||
id: string
|
||||
name: string
|
||||
socket: WASocket | null
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'qr_ready' | 'pairing'
|
||||
phoneNumber: string | null
|
||||
qrCode: string | null
|
||||
pairingCode: string | null
|
||||
reconnectAttempts: number
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface InstanceEvents {
|
||||
'instance.status': { instanceId: string; status: string; phoneNumber?: string }
|
||||
'instance.qr': { instanceId: string; qr: string; qrDataUrl: string }
|
||||
'instance.pairing': { instanceId: string; code: string }
|
||||
'message.received': { instanceId: string; message: any }
|
||||
'message.sent': { instanceId: string; message: any }
|
||||
'message.status': { instanceId: string; messageId: string; status: string }
|
||||
}
|
||||
|
||||
const logger = pino({ level: 'warn' })
|
||||
|
||||
class BaileysManager extends EventEmitter {
|
||||
private instances: Map<string, ManagedInstance> = new Map()
|
||||
private static instance: BaileysManager | null = null
|
||||
private initialized = false
|
||||
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
static getInstance(): BaileysManager {
|
||||
if (!BaileysManager.instance) {
|
||||
BaileysManager.instance = new BaileysManager()
|
||||
}
|
||||
return BaileysManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize manager and reconnect previously connected instances
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
|
||||
console.log('[BaileysManager] Initializing...')
|
||||
|
||||
try {
|
||||
// Load instances that were previously connected
|
||||
const result = await query<{ id: string; name: string; phone_number: string }>(
|
||||
`SELECT id, name, phone_number FROM instances WHERE status = 'connected'`
|
||||
)
|
||||
|
||||
for (const row of result.rows) {
|
||||
console.log(`[BaileysManager] Reconnecting instance: ${row.name}`)
|
||||
await this.connect(row.id)
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
console.log('[BaileysManager] Initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('[BaileysManager] Initialization error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect an instance to WhatsApp
|
||||
*/
|
||||
async connect(instanceId: string, usePairingCode = false, phoneNumber?: string): Promise<void> {
|
||||
// Check if already connected
|
||||
const existing = this.instances.get(instanceId)
|
||||
if (existing?.socket) {
|
||||
console.log(`[BaileysManager] Instance ${instanceId} already has active socket`)
|
||||
return
|
||||
}
|
||||
|
||||
// Load instance from DB
|
||||
const instanceResult = await query<{ id: string; name: string; phone_number: string }>(
|
||||
'SELECT id, name, phone_number FROM instances WHERE id = $1',
|
||||
[instanceId]
|
||||
)
|
||||
|
||||
if (instanceResult.rows.length === 0) {
|
||||
throw new Error(`Instance ${instanceId} not found`)
|
||||
}
|
||||
|
||||
const instanceData = instanceResult.rows[0]
|
||||
|
||||
// Update status
|
||||
await this.updateInstanceStatus(instanceId, 'connecting')
|
||||
|
||||
// Create managed instance
|
||||
const managed: ManagedInstance = {
|
||||
id: instanceId,
|
||||
name: instanceData.name,
|
||||
socket: null,
|
||||
status: 'connecting',
|
||||
phoneNumber: instanceData.phone_number || null,
|
||||
qrCode: null,
|
||||
pairingCode: null,
|
||||
reconnectAttempts: 0,
|
||||
lastError: null
|
||||
}
|
||||
this.instances.set(instanceId, managed)
|
||||
|
||||
try {
|
||||
// Load auth state from PostgreSQL
|
||||
const { state, saveCreds } = await usePostgresAuthState(instanceId)
|
||||
const { version } = await fetchLatestBaileysVersion()
|
||||
|
||||
// Create socket
|
||||
const socket = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger)
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
browser: Browsers.ubuntu('WhatsApp Nucleo'),
|
||||
logger,
|
||||
generateHighQualityLinkPreview: true,
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false
|
||||
})
|
||||
|
||||
managed.socket = socket
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers(instanceId, socket, saveCreds, usePairingCode, phoneNumber)
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[BaileysManager] Error connecting instance ${instanceId}:`, error)
|
||||
managed.lastError = (error as Error).message
|
||||
await this.updateInstanceStatus(instanceId, 'disconnected')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for a socket
|
||||
*/
|
||||
private setupEventHandlers(
|
||||
instanceId: string,
|
||||
socket: WASocket,
|
||||
saveCreds: () => Promise<void>,
|
||||
usePairingCode: boolean,
|
||||
phoneNumber?: string
|
||||
): void {
|
||||
const managed = this.instances.get(instanceId)!
|
||||
|
||||
// Credentials update
|
||||
socket.ev.on('creds.update', saveCreds)
|
||||
|
||||
// Connection update
|
||||
socket.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update
|
||||
|
||||
// QR Code received
|
||||
if (qr && !usePairingCode) {
|
||||
const qrDataUrl = await QRCode.toDataURL(qr)
|
||||
managed.qrCode = qrDataUrl
|
||||
managed.status = 'qr_ready'
|
||||
|
||||
await this.updateInstanceStatus(instanceId, 'qr_ready', { qr_code: qrDataUrl })
|
||||
this.emit('instance.qr', { instanceId, qr, qrDataUrl })
|
||||
}
|
||||
|
||||
// Request pairing code if needed
|
||||
if (usePairingCode && phoneNumber && !managed.pairingCode && connection === 'connecting') {
|
||||
try {
|
||||
// Wait a bit for socket to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
if (!socket.authState.creds.registered) {
|
||||
const code = await socket.requestPairingCode(phoneNumber)
|
||||
managed.pairingCode = code
|
||||
managed.status = 'pairing'
|
||||
|
||||
await this.updateInstanceStatus(instanceId, 'pairing', { pairing_code: code })
|
||||
this.emit('instance.pairing', { instanceId, code })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BaileysManager] Error requesting pairing code:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Connection closed
|
||||
if (connection === 'close') {
|
||||
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode
|
||||
const shouldReconnect = statusCode !== DisconnectReason.loggedOut
|
||||
|
||||
managed.socket = null
|
||||
managed.qrCode = null
|
||||
managed.pairingCode = null
|
||||
|
||||
if (shouldReconnect && managed.reconnectAttempts < 5) {
|
||||
managed.reconnectAttempts++
|
||||
console.log(`[BaileysManager] Reconnecting instance ${instanceId} (attempt ${managed.reconnectAttempts})`)
|
||||
|
||||
// Wait before reconnecting
|
||||
await new Promise(resolve => setTimeout(resolve, 3000 * managed.reconnectAttempts))
|
||||
await this.connect(instanceId)
|
||||
} else {
|
||||
managed.status = 'disconnected'
|
||||
await this.updateInstanceStatus(instanceId, 'disconnected')
|
||||
|
||||
if (statusCode === DisconnectReason.loggedOut) {
|
||||
// Clear auth state if logged out
|
||||
await clearAuthState(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('instance.status', { instanceId, status: 'disconnected' })
|
||||
}
|
||||
|
||||
// Connection open
|
||||
if (connection === 'open') {
|
||||
const phoneNum = socket.user?.id.split(':')[0] || null
|
||||
managed.status = 'connected'
|
||||
managed.phoneNumber = phoneNum
|
||||
managed.qrCode = null
|
||||
managed.pairingCode = null
|
||||
managed.reconnectAttempts = 0
|
||||
|
||||
await this.updateInstanceStatus(instanceId, 'connected', {
|
||||
phone_number: phoneNum,
|
||||
qr_code: null,
|
||||
pairing_code: null,
|
||||
last_connected_at: new Date()
|
||||
})
|
||||
|
||||
this.emit('instance.status', { instanceId, status: 'connected', phoneNumber: phoneNum || undefined })
|
||||
console.log(`[BaileysManager] Instance ${instanceId} connected as ${phoneNum}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Messages received
|
||||
socket.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
for (const msg of messages) {
|
||||
// Emit event for webhooks
|
||||
this.emit('message.received', { instanceId, message: msg })
|
||||
|
||||
// Save message to database
|
||||
await this.saveMessage(instanceId, msg, type === 'notify')
|
||||
}
|
||||
})
|
||||
|
||||
// Message status update
|
||||
socket.ev.on('messages.update', async (updates) => {
|
||||
for (const update of updates) {
|
||||
if (update.update.status) {
|
||||
this.emit('message.status', {
|
||||
instanceId,
|
||||
messageId: update.key.id!,
|
||||
status: this.mapMessageStatus(update.update.status)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect an instance
|
||||
*/
|
||||
async disconnect(instanceId: string): Promise<void> {
|
||||
const managed = this.instances.get(instanceId)
|
||||
if (!managed?.socket) return
|
||||
|
||||
managed.socket.end(new Error('Disconnected by user'))
|
||||
managed.socket = null
|
||||
managed.status = 'disconnected'
|
||||
managed.qrCode = null
|
||||
managed.pairingCode = null
|
||||
|
||||
await this.updateInstanceStatus(instanceId, 'disconnected')
|
||||
this.emit('instance.status', { instanceId, status: 'disconnected' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
async sendMessage(instanceId: string, jid: string, content: any): Promise<any> {
|
||||
const managed = this.instances.get(instanceId)
|
||||
if (!managed?.socket) {
|
||||
throw new Error('Instance not connected')
|
||||
}
|
||||
|
||||
const result = await managed.socket.sendMessage(jid, content)
|
||||
this.emit('message.sent', { instanceId, message: result })
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for an instance
|
||||
*/
|
||||
getQRCode(instanceId: string): string | null {
|
||||
return this.instances.get(instanceId)?.qrCode || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pairing code for an instance
|
||||
*/
|
||||
getPairingCode(instanceId: string): string | null {
|
||||
return this.instances.get(instanceId)?.pairingCode || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance status
|
||||
*/
|
||||
getStatus(instanceId: string): ManagedInstance | null {
|
||||
return this.instances.get(instanceId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all instances
|
||||
*/
|
||||
getAllInstances(): ManagedInstance[] {
|
||||
return Array.from(this.instances.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update instance status in database
|
||||
*/
|
||||
private async updateInstanceStatus(
|
||||
instanceId: string,
|
||||
status: string,
|
||||
extra: Record<string, any> = {}
|
||||
): Promise<void> {
|
||||
const fields = ['status = $2']
|
||||
const values: any[] = [instanceId, status]
|
||||
let paramIndex = 3
|
||||
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
fields.push(`${key} = $${paramIndex}`)
|
||||
values.push(value)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE instances SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`,
|
||||
values
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save message to database
|
||||
*/
|
||||
private async saveMessage(instanceId: string, msg: any, isNew: boolean): Promise<void> {
|
||||
try {
|
||||
const jid = msg.key.remoteJid
|
||||
if (!jid) return
|
||||
|
||||
// Ensure chat exists
|
||||
const chatResult = await query<{ id: string }>(
|
||||
`INSERT INTO chats (instance_id, jid, name, is_group)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (instance_id, jid) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[instanceId, jid, msg.pushName || jid.split('@')[0], jid.includes('@g.us')]
|
||||
)
|
||||
const chatId = chatResult.rows[0].id
|
||||
|
||||
// Get message content
|
||||
const content = msg.message?.conversation ||
|
||||
msg.message?.extendedTextMessage?.text ||
|
||||
msg.message?.imageMessage?.caption ||
|
||||
''
|
||||
|
||||
const messageType = this.getMessageType(msg.message)
|
||||
|
||||
// Insert message
|
||||
await query(
|
||||
`INSERT INTO messages (
|
||||
instance_id, chat_id, message_id, from_jid, from_me,
|
||||
message_type, content, timestamp, raw_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (instance_id, message_id) DO NOTHING`,
|
||||
[
|
||||
instanceId,
|
||||
chatId,
|
||||
msg.key.id,
|
||||
msg.key.fromMe ? 'me' : jid,
|
||||
msg.key.fromMe || false,
|
||||
messageType,
|
||||
content,
|
||||
new Date(msg.messageTimestamp! * 1000),
|
||||
JSON.stringify(msg)
|
||||
]
|
||||
)
|
||||
|
||||
// Update chat last message
|
||||
if (isNew) {
|
||||
await query(
|
||||
`UPDATE chats SET last_message_at = $1, unread_count = unread_count + 1 WHERE id = $2`,
|
||||
[new Date(msg.messageTimestamp! * 1000), chatId]
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BaileysManager] Error saving message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message type from Baileys message object
|
||||
*/
|
||||
private getMessageType(message: any): string {
|
||||
if (!message) return 'unknown'
|
||||
if (message.conversation || message.extendedTextMessage) return 'text'
|
||||
if (message.imageMessage) return 'image'
|
||||
if (message.videoMessage) return 'video'
|
||||
if (message.audioMessage) return 'audio'
|
||||
if (message.documentMessage) return 'document'
|
||||
if (message.stickerMessage) return 'sticker'
|
||||
if (message.contactMessage) return 'contact'
|
||||
if (message.locationMessage) return 'location'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Baileys message status to our status
|
||||
*/
|
||||
private mapMessageStatus(status: number): string {
|
||||
switch (status) {
|
||||
case 0: return 'pending'
|
||||
case 1: return 'sent'
|
||||
case 2: return 'delivered'
|
||||
case 3: return 'read'
|
||||
case 4: return 'read'
|
||||
default: return 'sent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const baileysManager = BaileysManager.getInstance()
|
||||
171
server/services/webhooks/dispatcher.ts
Normal file
171
server/services/webhooks/dispatcher.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Webhook Dispatcher
|
||||
* Sends events to configured webhooks
|
||||
*/
|
||||
import crypto from 'crypto'
|
||||
import { query } from '../../utils/database'
|
||||
import { baileysManager } from '../baileys/manager'
|
||||
|
||||
interface Webhook {
|
||||
id: string
|
||||
url: string
|
||||
secret: string | null
|
||||
events: string[]
|
||||
headers: Record<string, string>
|
||||
retry_count: number
|
||||
timeout_ms: number
|
||||
instance_id: string | null
|
||||
}
|
||||
|
||||
class WebhookDispatcher {
|
||||
private initialized = false
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return
|
||||
|
||||
// Listen to all Baileys events
|
||||
const events = [
|
||||
'message.received',
|
||||
'message.sent',
|
||||
'message.status',
|
||||
'instance.connected',
|
||||
'instance.disconnected',
|
||||
'instance.status',
|
||||
'instance.qr'
|
||||
]
|
||||
|
||||
for (const eventType of events) {
|
||||
baileysManager.on(eventType, (data: any) => {
|
||||
this.dispatch(data.instanceId || null, eventType, data)
|
||||
})
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
console.log('[WebhookDispatcher] Initialized')
|
||||
}
|
||||
|
||||
async dispatch(instanceId: string | null, eventType: string, payload: any) {
|
||||
try {
|
||||
// Get matching webhooks
|
||||
const webhooks = await query<Webhook>(
|
||||
`SELECT id, url, secret, events, headers, retry_count, timeout_ms, instance_id
|
||||
FROM webhooks
|
||||
WHERE is_active = TRUE
|
||||
AND $1 = ANY(events)
|
||||
AND (instance_id IS NULL OR instance_id = $2)`,
|
||||
[eventType, instanceId]
|
||||
)
|
||||
|
||||
for (const webhook of webhooks.rows) {
|
||||
this.deliverWithRetry(webhook, eventType, payload)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebhookDispatcher] Error dispatching:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverWithRetry(
|
||||
webhook: Webhook,
|
||||
eventType: string,
|
||||
payload: any,
|
||||
attempt = 1
|
||||
) {
|
||||
try {
|
||||
await this.deliver(webhook, eventType, payload)
|
||||
|
||||
// Log success
|
||||
await this.logDelivery(webhook.id, eventType, payload, {
|
||||
status: 200,
|
||||
attempt,
|
||||
success: true
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
|
||||
// Log failure
|
||||
await this.logDelivery(webhook.id, eventType, payload, {
|
||||
status: 0,
|
||||
attempt,
|
||||
success: false,
|
||||
error: errorMessage
|
||||
})
|
||||
|
||||
// Retry if under limit
|
||||
if (attempt < webhook.retry_count) {
|
||||
const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
|
||||
setTimeout(() => {
|
||||
this.deliverWithRetry(webhook, eventType, payload, attempt + 1)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(webhook: Webhook, eventType: string, payload: any) {
|
||||
const body = JSON.stringify({
|
||||
event: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: payload
|
||||
})
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Event': eventType,
|
||||
'X-Webhook-Timestamp': Date.now().toString(),
|
||||
...(webhook.headers || {})
|
||||
}
|
||||
|
||||
// Add HMAC signature if secret is configured
|
||||
if (webhook.secret) {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
|
||||
|
||||
try {
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private async logDelivery(
|
||||
webhookId: string,
|
||||
eventType: string,
|
||||
payload: any,
|
||||
result: { status: number; attempt: number; success: boolean; error?: string }
|
||||
) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message, attempt, delivered_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
webhookId,
|
||||
eventType,
|
||||
JSON.stringify(payload),
|
||||
result.status,
|
||||
result.error || null,
|
||||
result.attempt,
|
||||
result.success ? new Date() : null
|
||||
]
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[WebhookDispatcher] Error logging delivery:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const webhookDispatcher = new WebhookDispatcher()
|
||||
52
server/utils/database.ts
Normal file
52
server/utils/database.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
let pool: pg.Pool | null = null
|
||||
|
||||
export function getPool(): pg.Pool {
|
||||
if (!pool) {
|
||||
const config = useRuntimeConfig()
|
||||
pool = new Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err)
|
||||
})
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function query<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
return await client.query<T>(text, params)
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
callback: (client: pg.PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const result = await callback(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user