/** * POST /api/messages/:instanceId/:chatId/send * Unified endpoint for sending all message types: * - Text messages (JSON body) * - Media messages: image, video, audio, document (FormData) * - Stickers: image converted to WebP (FormData with asSticker flag) */ import type { AnyMediaMessageContent } from '@whiskeysockets/baileys' import { generateWAMessageFromContent, proto } from '@whiskeysockets/baileys' import { query } from '../../../../utils/database' import { baileysManager } from '../../../../services/baileys/manager' import { convertToSticker, canConvertToSticker } from '../../../../services/media/sticker-processor' // 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 sticker: 500 * 1024, // 500 KB (before conversion) } // MIME type to media type mapping function getMediaType(mimetype: string): 'image' | 'video' | 'audio' | 'document' { if (mimetype.startsWith('image/')) return 'image' if (mimetype.startsWith('video/')) return 'video' if (mimetype.startsWith('audio/')) return 'audio' return 'document' } interface TextMessageBody { content?: string message?: string quotedMessageId?: string } interface ContactInfo { displayName: string phoneNumber: string organization?: string } interface ContactMessageBody { type: 'contact' contacts: ContactInfo[] quotedMessageId?: string } interface PollMessageBody { type: 'poll' name: string options: string[] selectableCount?: number quotedMessageId?: string } interface EventMessageBody { type: 'event' name: string startDate: string // ISO date string endDate?: string description?: string location?: { name?: string address?: string latitude?: number longitude?: number } quotedMessageId?: string } type JsonMessageBody = TextMessageBody | ContactMessageBody | PollMessageBody | EventMessageBody 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' }) } // Detect Content-Type to determine message type const contentType = getHeader(event, 'content-type') || '' if (contentType.includes('multipart/form-data')) { // ==================== MEDIA / STICKER ==================== return await handleMediaMessage(event, instanceId, chatId, jid, socket) } else { // ==================== JSON MESSAGES ==================== return await handleJsonMessage(event, instanceId, chatId, jid, socket) } }) /** * Handle JSON message sending (text, contacts, polls, events) */ async function handleJsonMessage( event: any, instanceId: string, chatId: string, jid: string, socket: any ) { const body = await readBody(event) // Determine message type const messageType = (body as any).type || 'text' // Get quoted message if provided let quotedMessage = null const quotedMessageId = (body as any).quotedMessageId 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 options: any = {} if (quotedMessage) { options.quoted = quotedMessage } try { let content: any let dbMessageType: string let dbContent: string switch (messageType) { case 'contact': return await handleContactMessage(body as ContactMessageBody, instanceId, chatId, jid, socket, options) case 'poll': return await handlePollMessage(body as PollMessageBody, instanceId, chatId, jid, socket, options) case 'event': return await handleEventMessage(body as EventMessageBody, instanceId, chatId, jid, socket, options) default: // Text message const textBody = body as TextMessageBody const messageText = textBody.content || textBody.message if (!messageText?.trim()) { throw createError({ statusCode: 400, message: 'Message content is required' }) } content = { text: messageText } dbMessageType = 'text' dbContent = messageText const result = await socket.sendMessage(jid, content, options) // Save to database await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, message_type, content, timestamp, status, raw_message, quoted_message_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) ON CONFLICT (instance_id, message_id) DO NOTHING`, [ instanceId, chatId, result.key.id, 'me', true, dbMessageType, dbContent, 'sent', JSON.stringify(result), quotedMessageId || null ] ) await query( `UPDATE chats SET last_message_at = NOW(), last_message_type = $1 WHERE id = $2`, [dbMessageType, chatId] ) return { success: true, messages: [{ messageId: result.key.id, type: dbMessageType }] } } } catch (error) { console.error('[Send] Error sending message:', error) throw createError({ statusCode: 500, message: `Failed to send message: ${(error as Error).message}` }) } } /** * Handle contact message sending */ async function handleContactMessage( body: ContactMessageBody, instanceId: string, chatId: string, jid: string, socket: any, options: any ) { if (!body.contacts || body.contacts.length === 0) { throw createError({ statusCode: 400, message: 'At least one contact is required' }) } // Build vCards for each contact const vcards = body.contacts.map(contact => { const lines = [ 'BEGIN:VCARD', 'VERSION:3.0', `FN:${contact.displayName}`, `TEL;type=CELL;waid=${contact.phoneNumber.replace(/\D/g, '')}:${contact.phoneNumber}` ] if (contact.organization) { lines.push(`ORG:${contact.organization}`) } lines.push('END:VCARD') return lines.join('\n') }) const content = { contacts: { displayName: body.contacts.length === 1 ? body.contacts[0].displayName : `${body.contacts.length} contactos`, contacts: vcards.map(vcard => ({ vcard })) } } const result = await socket.sendMessage(jid, content, options) // Save to database await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, message_type, content, timestamp, status, raw_message, quoted_message_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) ON CONFLICT (instance_id, message_id) DO NOTHING`, [ instanceId, chatId, result.key.id, 'me', true, 'contact', JSON.stringify(body.contacts), 'sent', JSON.stringify(result), body.quotedMessageId || null ] ) await query( `UPDATE chats SET last_message_at = NOW(), last_message_type = 'contact' WHERE id = $1`, [chatId] ) return { success: true, messages: [{ messageId: result.key.id, type: 'contact' }] } } /** * Handle poll message sending */ async function handlePollMessage( body: PollMessageBody, instanceId: string, chatId: string, jid: string, socket: any, options: any ) { if (!body.name?.trim()) { throw createError({ statusCode: 400, message: 'Poll name is required' }) } if (!body.options || body.options.length < 2) { throw createError({ statusCode: 400, message: 'At least 2 poll options are required' }) } if (body.options.length > 12) { throw createError({ statusCode: 400, message: 'Maximum 12 poll options allowed' }) } const content = { poll: { name: body.name, values: body.options, selectableCount: body.selectableCount || 1 } } const result = await socket.sendMessage(jid, content, options) // Save to database await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, message_type, content, timestamp, status, raw_message, quoted_message_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) ON CONFLICT (instance_id, message_id) DO NOTHING`, [ instanceId, chatId, result.key.id, 'me', true, 'poll', JSON.stringify({ name: body.name, options: body.options, selectableCount: body.selectableCount || 1 }), 'sent', JSON.stringify(result), body.quotedMessageId || null ] ) await query( `UPDATE chats SET last_message_at = NOW(), last_message_type = 'poll' WHERE id = $1`, [chatId] ) return { success: true, messages: [{ messageId: result.key.id, type: 'poll' }] } } /** * Handle event message sending * Note: Baileys' sendMessage doesn't handle 'event' type directly, * so we build the proto message manually and use relayMessage */ async function handleEventMessage( body: EventMessageBody, instanceId: string, chatId: string, jid: string, socket: any, options: any ) { if (!body.name?.trim()) { throw createError({ statusCode: 400, message: 'Event name is required' }) } if (!body.startDate) { throw createError({ statusCode: 400, message: 'Event start date is required' }) } // Build the eventMessage proto directly const eventMessage: proto.Message.IEventMessage = { name: body.name, startTime: Math.floor(new Date(body.startDate).getTime() / 1000), description: body.description || undefined, extraGuestsAllowed: true } if (body.endDate) { eventMessage.endTime = Math.floor(new Date(body.endDate).getTime() / 1000) } if (body.location && body.location.latitude && body.location.longitude) { eventMessage.location = { degreesLatitude: body.location.latitude, degreesLongitude: body.location.longitude, name: body.location.name || undefined, address: body.location.address || undefined } } // Create the message content with eventMessage const messageContent: proto.IMessage = { eventMessage } // Generate the full WAMessage using generateWAMessageFromContent const userJid = socket.user?.id const generatedMessage = generateWAMessageFromContent( jid, messageContent, { userJid, quoted: options.quoted, timestamp: new Date() } ) // Use relayMessage to send the pre-built message await socket.relayMessage(jid, generatedMessage.message!, { messageId: generatedMessage.key.id }) // Save to database await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, message_type, content, timestamp, status, raw_message, quoted_message_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) ON CONFLICT (instance_id, message_id) DO NOTHING`, [ instanceId, chatId, generatedMessage.key.id, 'me', true, 'event', JSON.stringify({ name: body.name, startDate: body.startDate, endDate: body.endDate, description: body.description, location: body.location }), 'sent', JSON.stringify(generatedMessage), body.quotedMessageId || null ] ) await query( `UPDATE chats SET last_message_at = NOW(), last_message_type = 'event' WHERE id = $1`, [chatId] ) return { success: true, messages: [{ messageId: generatedMessage.key.id, type: 'event' }] } } /** * Handle media/sticker message sending */ async function handleMediaMessage( event: any, instanceId: string, chatId: string, jid: string, socket: any ) { const formData = await readMultipartFormData(event) if (!formData) { throw createError({ statusCode: 400, message: 'No form data received' }) } // Extract fields from FormData let caption = '' let quotedMessageId = '' let isPtt = false const asSticker: boolean[] = [] 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 === 'asSticker' && item.data) { // Parse asSticker array (sent as JSON string) const rawValue = item.data.toString() console.log(`[Send] Received asSticker raw value: ${rawValue}`) try { const parsed = JSON.parse(rawValue) console.log(`[Send] Parsed asSticker:`, parsed) if (Array.isArray(parsed)) { asSticker.push(...parsed) } } catch { // If not JSON, try comma-separated values const values = rawValue.split(',') asSticker.push(...values.map(v => v.trim() === 'true')) } console.log(`[Send] Final asSticker array:`, asSticker) } else if ((item.name === 'files' || item.name === 'file') && 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 console.log(`[Send] Processing ${files.length} files, asSticker array:`, asSticker) for (let index = 0; index < files.length; index++) { const file = files[index] const sendAsSticker = asSticker[index] === true && canConvertToSticker(file.type) const mediaType = getMediaType(file.type) console.log(`[Send] File ${index}: ${file.name}, type: ${file.type}, sendAsSticker: ${sendAsSticker}, asSticker[${index}]: ${asSticker[index]}`) // Validate file size if (sendAsSticker) { // For stickers, we allow larger input since we'll compress if (file.data.length > 5 * 1024 * 1024) { // 5MB max input for sticker throw createError({ statusCode: 400, message: `File ${file.name} is too large for sticker conversion` }) } } else { const maxSize = MAX_SIZES[mediaType] if (file.data.length > maxSize) { throw createError({ statusCode: 400, message: `File ${file.name} exceeds maximum size for ${mediaType}` }) } } try { let content: any let messageType: string if (sendAsSticker) { // ==================== STICKER ==================== console.log(`[Send] Converting ${file.name} to sticker...`) const stickerResult = await convertToSticker(file.data, file.type) console.log(`[Send] Sticker converted: ${stickerResult.buffer.length} bytes`) content = { sticker: stickerResult.buffer } messageType = 'sticker' } else { // ==================== REGULAR MEDIA ==================== messageType = mediaType if (mediaType === 'image') { content = { image: file.data, caption: caption || undefined, mimetype: file.type } as AnyMediaMessageContent } else if (mediaType === 'video') { content = { video: file.data, caption: caption || undefined, mimetype: file.type } as AnyMediaMessageContent } else if (mediaType === 'audio') { // For PTT (voice notes), WhatsApp requires audio/ogg; codecs=opus // If the browser sends webm, we force the mimetype to ogg // The opus codec is the same, just different container let audioMimetype = file.type if (isPtt && file.type.includes('webm')) { audioMimetype = 'audio/ogg; codecs=opus' console.log(`[Send] Converting PTT mimetype from ${file.type} to ${audioMimetype}`) } content = { audio: file.data, ptt: isPtt, mimetype: audioMimetype } as AnyMediaMessageContent } else { // Document content = { document: file.data, fileName: file.name, caption: caption || undefined, mimetype: file.type } as AnyMediaMessageContent } } // Build options with quoted message if exists const options: any = {} if (quotedMessage) { options.quoted = quotedMessage } // Send message console.log(`[Send] Sending ${messageType} to ${jid}`) const result = await socket.sendMessage(jid, content, options) if (result) { sentMessages.push({ messageId: result.key.id, type: messageType, filename: file.name }) // Save to database await saveMediaMessage(instanceId, chatId, jid, result, messageType, sendAsSticker ? '' : caption, file.name) } // Only use caption for first non-sticker file if (!sendAsSticker) { caption = '' } } catch (error) { console.error(`[Send] Error sending ${file.name}:`, error) throw createError({ statusCode: 500, message: `Error sending ${file.name}: ${(error as Error).message}` }) } } 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, jid, true, messageType, caption || null, caption || null, filename, timestamp, 'sent', JSON.stringify(result) ] ) await query( `UPDATE chats SET last_message_at = $1, last_message_type = $2 WHERE id = $3`, [timestamp, messageType, chatId] ) }