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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user