Feature: Agregar botón para crear webhook de debug automáticamente
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s

- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection
- Detectar si ya existe un webhook apuntando al receptor de debug
- Permitir eliminar el webhook de debug
- Incluir todos los eventos disponibles al crear el webhook
- También incluye mejoras previas de manejo de media y mensajes
This commit is contained in:
2025-12-02 21:21:33 -06:00
parent 71593b25e9
commit 80d0042c7e
21 changed files with 3722 additions and 112 deletions

View File

@@ -1,6 +1,6 @@
/**
* GET /api/messages/:instanceId/:chatId
* Get messages for a chat
* Get messages for a chat with full parsed data from raw_message
*/
import { query } from '../../../../utils/database'
@@ -15,6 +15,201 @@ interface MessageRow {
media_url: string | null
timestamp: Date
status: string
raw_message: any
participant_jid: string | null
push_name: string | null
quoted_message_id: string | null
}
interface ChatRow {
is_group: boolean
}
// Parse different message types from raw Baileys message
function parseRawMessage(raw: any, messageType: string) {
if (!raw?.message) return {}
const msg = raw.message
const result: any = {}
// Extract media info based on type
if (messageType === 'image' && msg.imageMessage) {
result.media = {
mimetype: msg.imageMessage.mimetype,
filesize: msg.imageMessage.fileLength ? Number(msg.imageMessage.fileLength) : undefined,
width: msg.imageMessage.width,
height: msg.imageMessage.height,
thumbnail: msg.imageMessage.jpegThumbnail
? Buffer.from(msg.imageMessage.jpegThumbnail).toString('base64')
: undefined,
isViewOnce: !!msg.imageMessage.viewOnce
}
result.caption = msg.imageMessage.caption
}
if (messageType === 'video' && msg.videoMessage) {
result.media = {
mimetype: msg.videoMessage.mimetype,
filesize: msg.videoMessage.fileLength ? Number(msg.videoMessage.fileLength) : undefined,
width: msg.videoMessage.width,
height: msg.videoMessage.height,
duration: msg.videoMessage.seconds,
thumbnail: msg.videoMessage.jpegThumbnail
? Buffer.from(msg.videoMessage.jpegThumbnail).toString('base64')
: undefined,
isViewOnce: !!msg.videoMessage.viewOnce
}
result.caption = msg.videoMessage.caption
}
if (messageType === 'audio' && msg.audioMessage) {
result.media = {
mimetype: msg.audioMessage.mimetype,
filesize: msg.audioMessage.fileLength ? Number(msg.audioMessage.fileLength) : undefined,
duration: msg.audioMessage.seconds,
isPtt: !!msg.audioMessage.ptt,
waveform: msg.audioMessage.waveform
? Array.from(msg.audioMessage.waveform)
: undefined
}
}
if (messageType === 'document' && msg.documentMessage) {
result.media = {
mimetype: msg.documentMessage.mimetype,
filename: msg.documentMessage.fileName,
filesize: msg.documentMessage.fileLength ? Number(msg.documentMessage.fileLength) : undefined,
thumbnail: msg.documentMessage.jpegThumbnail
? Buffer.from(msg.documentMessage.jpegThumbnail).toString('base64')
: undefined
}
result.caption = msg.documentMessage.caption
}
if (messageType === 'sticker' && msg.stickerMessage) {
result.media = {
mimetype: msg.stickerMessage.mimetype,
width: msg.stickerMessage.width,
height: msg.stickerMessage.height,
filesize: msg.stickerMessage.fileLength ? Number(msg.stickerMessage.fileLength) : undefined
}
}
if (messageType === 'contact' && msg.contactMessage) {
const vcard = msg.contactMessage.vcard || ''
const phones = extractPhonesFromVCard(vcard)
result.contact = {
displayName: msg.contactMessage.displayName,
vcard: vcard,
phones: phones
}
}
if (messageType === 'location' && msg.locationMessage) {
result.location = {
latitude: msg.locationMessage.degreesLatitude,
longitude: msg.locationMessage.degreesLongitude,
name: msg.locationMessage.name,
address: msg.locationMessage.address,
url: msg.locationMessage.url
}
}
// Extract quoted message if exists
const contextInfo = getContextInfo(msg)
if (contextInfo?.quotedMessage) {
const quotedType = getMessageType(contextInfo.quotedMessage)
result.quoted = {
id: contextInfo.stanzaId,
content: extractTextContent(contextInfo.quotedMessage),
type: quotedType,
fromMe: contextInfo.participant === raw.key?.participant || false,
participant: contextInfo.participant,
participantName: null // Will be filled from contacts if needed
}
// Add media thumbnail for quoted media messages
if (['image', 'video', 'sticker'].includes(quotedType)) {
const quotedMedia = contextInfo.quotedMessage[`${quotedType}Message`]
if (quotedMedia?.jpegThumbnail) {
result.quoted.media = {
thumbnail: Buffer.from(quotedMedia.jpegThumbnail).toString('base64')
}
}
}
}
// Extract participant for group messages
if (raw.key?.participant) {
result.participant = raw.key.participant
}
// Extract pushName
if (raw.pushName) {
result.pushName = raw.pushName
}
return result
}
// Get context info from any message type
function getContextInfo(msg: any): any {
const messageTypes = [
'extendedTextMessage',
'imageMessage',
'videoMessage',
'audioMessage',
'documentMessage',
'stickerMessage',
'contactMessage',
'locationMessage'
]
for (const type of messageTypes) {
if (msg[type]?.contextInfo) {
return msg[type].contextInfo
}
}
return null
}
// Get message type from message content
function getMessageType(msg: any): string {
if (msg.conversation || msg.extendedTextMessage) return 'text'
if (msg.imageMessage) return 'image'
if (msg.videoMessage) return 'video'
if (msg.audioMessage) return 'audio'
if (msg.documentMessage) return 'document'
if (msg.stickerMessage) return 'sticker'
if (msg.contactMessage) return 'contact'
if (msg.locationMessage) return 'location'
return 'unknown'
}
// Extract text content from any message type
function extractTextContent(msg: any): string | null {
if (msg.conversation) return msg.conversation
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text
if (msg.imageMessage?.caption) return msg.imageMessage.caption
if (msg.videoMessage?.caption) return msg.videoMessage.caption
if (msg.documentMessage?.caption) return msg.documentMessage.caption
return null
}
// Extract phone numbers from vCard
function extractPhonesFromVCard(vcard: string): string[] {
const phones: string[] = []
const telRegex = /TEL[^:]*:([^\n\r]+)/gi
let match
while ((match = telRegex.exec(vcard)) !== null) {
const phone = match[1].trim().replace(/[^\d+]/g, '')
if (phone) phones.push(phone)
}
return phones
}
export default defineEventHandler(async (event) => {
@@ -30,26 +225,38 @@ export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
const offset = parseInt(queryParams.offset as string) || 0
const before = queryParams.before as string // For infinite scroll
// Verify chat exists and belongs to instance
const chatCheck = await query(
'SELECT id FROM chats WHERE id = $1 AND instance_id = $2',
// Verify chat exists and get info
const chatCheck = await query<ChatRow>(
'SELECT id, is_group 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]
)
const isGroup = chatCheck.rows[0].is_group
// Build query with optional before parameter for infinite scroll
let messagesQuery = `
SELECT id, message_id, from_jid, from_me, message_type,
content, caption, media_url, timestamp, status,
raw_message, participant_jid, push_name, quoted_message_id
FROM messages
WHERE chat_id = $1
`
const params: any[] = [chatId]
if (before) {
messagesQuery += ` AND timestamp < $${params.length + 1}`
params.push(before)
}
messagesQuery += ` ORDER BY timestamp DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(limit, offset)
const result = await query<MessageRow>(messagesQuery, params)
// Mark as read
await query(
@@ -57,16 +264,28 @@ export default defineEventHandler(async (event) => {
[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
}))
// Parse and return messages
return result.rows.map(row => {
const parsedData = parseRawMessage(row.raw_message, row.message_type)
return {
id: row.id,
messageId: row.message_id,
chatId: chatId,
fromJid: row.from_jid,
fromMe: row.from_me,
type: row.message_type || 'unknown',
content: row.content,
caption: row.caption || parsedData.caption,
media: parsedData.media || (row.media_url ? { url: row.media_url } : undefined),
location: parsedData.location,
contact: parsedData.contact,
quoted: parsedData.quoted,
timestamp: row.timestamp,
status: row.status || 'sent',
participant: row.participant_jid || parsedData.participant,
pushName: row.push_name || parsedData.pushName,
isGroup: isGroup
}
})
})

View File

@@ -0,0 +1,234 @@
/**
* POST /api/messages/:instanceId/:chatId/send-media
* Send media messages (images, videos, audio, documents)
*/
import { prepareWAMessageMedia, type AnyMediaMessageContent } from '@whiskeysockets/baileys'
import { baileysManager } from '../../../../services/baileys/manager'
import { query } from '../../../../utils/database'
// Max file sizes (in bytes)
const MAX_SIZES = {
image: 16 * 1024 * 1024, // 16 MB
video: 64 * 1024 * 1024, // 64 MB
audio: 16 * 1024 * 1024, // 16 MB
document: 100 * 1024 * 1024, // 100 MB
}
// MIME type to media type mapping
function getMediaType(mimetype: string): 'image' | 'video' | 'audio' | 'document' | null {
if (mimetype.startsWith('image/')) return 'image'
if (mimetype.startsWith('video/')) return 'video'
if (mimetype.startsWith('audio/')) return 'audio'
// Everything else is a document
return 'document'
}
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')
if (!instanceId || !chatId) {
throw createError({ statusCode: 400, message: 'Missing instanceId or chatId' })
}
// Get chat JID
const chatResult = await query(
'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
// Get socket
const socket = baileysManager.getSocket(instanceId)
if (!socket) {
throw createError({ statusCode: 400, message: 'Instance not connected' })
}
// Parse multipart form data
const formData = await readMultipartFormData(event)
if (!formData) {
throw createError({ statusCode: 400, message: 'No form data received' })
}
// Extract fields
let caption = ''
let quotedMessageId = ''
let isPtt = false
const files: { name: string; data: Buffer; type: string }[] = []
for (const item of formData) {
if (item.name === 'caption' && item.data) {
caption = item.data.toString()
} else if (item.name === 'quotedMessageId' && item.data) {
quotedMessageId = item.data.toString()
} else if (item.name === 'isPtt' && item.data) {
isPtt = item.data.toString() === 'true'
} else if (item.name === 'files' || item.name === 'file') {
if (item.data && item.type) {
files.push({
name: item.filename || 'file',
data: item.data,
type: item.type
})
}
}
}
if (files.length === 0) {
throw createError({ statusCode: 400, message: 'No files provided' })
}
// Get quoted message if provided
let quotedMessage = null
if (quotedMessageId) {
const quotedResult = await query(
'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2',
[quotedMessageId, instanceId]
)
if (quotedResult.rows.length > 0) {
quotedMessage = quotedResult.rows[0].raw_message
}
}
const sentMessages = []
// Send each file
for (const file of files) {
const mediaType = getMediaType(file.type)
if (!mediaType) {
console.warn(`[SendMedia] Unknown media type: ${file.type}`)
continue
}
// Check file size
const maxSize = MAX_SIZES[mediaType]
if (file.data.length > maxSize) {
throw createError({
statusCode: 400,
message: `File ${file.name} exceeds maximum size for ${mediaType}`
})
}
try {
// Prepare media content
let content: AnyMediaMessageContent
if (mediaType === 'image') {
content = {
image: file.data,
caption: caption || undefined,
mimetype: file.type as any
}
} else if (mediaType === 'video') {
content = {
video: file.data,
caption: caption || undefined,
mimetype: file.type as any
}
} else if (mediaType === 'audio') {
content = {
audio: file.data,
ptt: isPtt,
mimetype: file.type as any
}
} else {
// Document
content = {
document: file.data,
fileName: file.name,
caption: caption || undefined,
mimetype: file.type as any
}
}
// Add quoted message if exists
if (quotedMessage) {
(content as any).quoted = quotedMessage
}
// Send message
console.log(`[SendMedia] Sending ${mediaType} to ${jid}`)
const result = await socket.sendMessage(jid, content)
if (result) {
sentMessages.push({
messageId: result.key.id,
type: mediaType,
filename: file.name
})
// Save to database
await saveMediaMessage(instanceId, chatId, jid, result, mediaType, caption, file.name)
}
// Only use caption for first file
caption = ''
} catch (error) {
console.error(`[SendMedia] Error sending ${file.name}:`, error)
throw createError({
statusCode: 500,
message: `Error sending ${file.name}`
})
}
}
return {
success: true,
messages: sentMessages
}
})
// Helper to save media message to database
async function saveMediaMessage(
instanceId: string,
chatId: string,
jid: string,
result: any,
messageType: string,
caption: string,
filename: string
) {
const messageId = result.key.id
const timestamp = new Date()
await query(
`INSERT INTO messages (
instance_id, chat_id, message_id, from_jid, to_jid,
from_me, message_type, content, caption, media_filename,
timestamp, status, raw_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (instance_id, message_id) DO NOTHING`,
[
instanceId,
chatId,
messageId,
jid, // from_jid will be our JID
jid, // to_jid
true, // from_me
messageType,
caption || null,
caption || null,
filename,
timestamp,
'sent',
JSON.stringify(result)
]
)
// Update chat last message
await query(
`UPDATE chats SET last_message_at = $1, last_message_type = $2 WHERE id = $3`,
[timestamp, messageType, chatId]
)
}