All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m28s
- Consolidar send.post.ts y send-media.post.ts en un único endpoint /send - Agregar servicio sticker-processor.ts para convertir imágenes a WebP 512x512 - Agregar toggle Imagen/Sticker en MediaPreview para enviar imágenes como stickers - Actualizar MessageInput y página de mensajes para usar endpoint unificado - Instalar dependencia sharp para procesamiento de imágenes
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
/**
|
|
* 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 { 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
|
|
}
|
|
|
|
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 {
|
|
// ==================== TEXT ====================
|
|
return await handleTextMessage(event, instanceId, chatId, jid)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Handle text message sending
|
|
*/
|
|
async function handleTextMessage(
|
|
event: any,
|
|
instanceId: string,
|
|
chatId: string,
|
|
jid: string
|
|
) {
|
|
const body = await readBody<TextMessageBody>(event)
|
|
const messageText = body.content || body.message
|
|
|
|
if (!messageText?.trim()) {
|
|
throw createError({ statusCode: 400, message: 'Message content is required' })
|
|
}
|
|
|
|
// Get quoted message if provided
|
|
let quotedMessage = null
|
|
if (body.quotedMessageId) {
|
|
const quotedResult = await query(
|
|
'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2',
|
|
[body.quotedMessageId, instanceId]
|
|
)
|
|
if (quotedResult.rows.length > 0) {
|
|
quotedMessage = quotedResult.rows[0].raw_message
|
|
}
|
|
}
|
|
|
|
try {
|
|
const content = { text: messageText }
|
|
const options: any = {}
|
|
if (quotedMessage) {
|
|
options.quoted = quotedMessage
|
|
}
|
|
|
|
const result = await baileysManager.sendMessage(instanceId, 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,
|
|
'text',
|
|
messageText,
|
|
'sent',
|
|
JSON.stringify(result),
|
|
body.quotedMessageId || null
|
|
]
|
|
)
|
|
|
|
await query(
|
|
`UPDATE chats SET last_message_at = NOW(), last_message_type = 'text' WHERE id = $1`,
|
|
[chatId]
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
messages: [{ messageId: result.key.id, type: 'text' }]
|
|
}
|
|
} catch (error) {
|
|
console.error('[Send] Error sending text:', error)
|
|
throw createError({
|
|
statusCode: 500,
|
|
message: `Failed to send message: ${(error as Error).message}`
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
try {
|
|
const parsed = JSON.parse(item.data.toString())
|
|
if (Array.isArray(parsed)) {
|
|
asSticker.push(...parsed)
|
|
}
|
|
} catch {
|
|
// If not JSON, try comma-separated values
|
|
const values = item.data.toString().split(',')
|
|
asSticker.push(...values.map(v => v.trim() === 'true'))
|
|
}
|
|
} 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
|
|
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)
|
|
|
|
// 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') {
|
|
content = {
|
|
audio: file.data,
|
|
ptt: isPtt,
|
|
mimetype: file.type
|
|
} 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]
|
|
)
|
|
}
|