diff --git a/app/components/messages/MediaPreview.vue b/app/components/messages/MediaPreview.vue index 1370ad5..e8ce0ef 100644 --- a/app/components/messages/MediaPreview.vue +++ b/app/components/messages/MediaPreview.vue @@ -7,15 +7,27 @@ :key="index" class="relative group" > - +
+ + +
- -
+ +
+ + +

+ Los stickers se convertiran a formato 512x512 WebP +

@@ -101,16 +118,45 @@ interface Props { showCaption?: boolean } -withDefaults(defineProps(), { +const props = withDefaults(defineProps(), { caption: '', showCaption: true }) -defineEmits<{ +const emit = defineEmits<{ remove: [index: number] 'update:caption': [value: string] + 'update:stickerModes': [modes: boolean[]] }>() +// Track which images should be sent as stickers +const stickerModes = ref([]) + +// Initialize sticker modes when files change +watch(() => props.files, (files) => { + // Preserve existing modes and add false for new files + const newModes = files.map((_, i) => stickerModes.value[i] ?? false) + stickerModes.value = newModes + emit('update:stickerModes', [...newModes]) +}, { immediate: true }) + +const toggleStickerMode = (index: number) => { + stickerModes.value[index] = !stickerModes.value[index] + emit('update:stickerModes', [...stickerModes.value]) +} + +// Check if all files are stickers (images marked as sticker) +const allStickers = computed(() => { + return props.files.length > 0 && props.files.every((f, i) => + isImage(f) && stickerModes.value[i] + ) +}) + +// Check if any file is marked as sticker +const hasStickers = computed(() => { + return props.files.some((f, i) => isImage(f) && stickerModes.value[i]) +}) + // Preview URL cache const previewUrls = new Map() diff --git a/app/components/messages/MessageInput.vue b/app/components/messages/MessageInput.vue index f9fac3d..df15694 100644 --- a/app/components/messages/MessageInput.vue +++ b/app/components/messages/MessageInput.vue @@ -29,6 +29,7 @@ :caption="caption" @remove="removeFile" @update:caption="caption = $event" + @update:stickerModes="stickerModes = $event" /> @@ -163,7 +164,7 @@ const props = withDefaults(defineProps(), { }) const emit = defineEmits<{ - send: [content: string, files: File[], caption: string, quotedId?: string] + send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]] sendVoice: [audioFile: File] cancelReply: [] typing: [] @@ -181,6 +182,7 @@ const message = ref('') const selectedFiles = ref([]) const caption = ref('') const isDragging = ref(false) +const stickerModes = ref([]) // Audio recorder const { @@ -258,13 +260,15 @@ const handleSend = () => { message.value.trim(), [...selectedFiles.value], caption.value, - props.replyingTo?.messageId + props.replyingTo?.messageId, + [...stickerModes.value] ) // Clear state message.value = '' selectedFiles.value = [] caption.value = '' + stickerModes.value = [] } const startRecording = async () => { diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue index a57fb05..ff1c552 100644 --- a/app/pages/messages/index.vue +++ b/app/pages/messages/index.vue @@ -496,14 +496,15 @@ const copyToClipboard = async (text: string) => { // Reply state (will be used for quote functionality) const replyingTo = ref(null) -const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string) => { +const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]) => { if (!selectedInstance.value?.value || !selectedChat.value) return try { const instanceId = selectedInstance.value.value const chatId = selectedChat.value.id + const endpoint = `/api/messages/${instanceId}/${chatId}/send` - // If we have files, send as media + // If we have files, send as media (with optional sticker flags) if (files.length > 0) { const formData = new FormData() @@ -522,13 +523,18 @@ const handleSendMessage = async (content: string, files: File[], caption: string formData.append('quotedMessageId', quotedId || replyingTo.value.messageId) } - await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, { + // Add sticker modes if any images should be sent as stickers + if (stickerModes && stickerModes.length > 0) { + formData.append('asSticker', JSON.stringify(stickerModes)) + } + + await $fetch(endpoint, { method: 'POST', body: formData }) } else if (content) { // Send text message - await $fetch(`/api/messages/${instanceId}/${chatId}/send`, { + await $fetch(endpoint, { method: 'POST', body: { content, @@ -559,7 +565,7 @@ const handleSendVoice = async (audioFile: File) => { formData.append('files', audioFile) formData.append('isPtt', 'true') // Mark as push-to-talk (voice note) - await $fetch(`/api/messages/${instanceId}/${chatId}/send-media`, { + await $fetch(`/api/messages/${instanceId}/${chatId}/send`, { method: 'POST', body: formData }) diff --git a/package-lock.json b/package-lock.json index 1e383cc..ffddb9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "pg": "^8.13.1", "pino": "^9.5.0", "qrcode": "^1.5.4", + "sharp": "^0.34.5", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -2327,7 +2328,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2344,7 +2344,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2367,7 +2366,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2390,7 +2388,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2407,7 +2404,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2424,7 +2420,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2441,7 +2436,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2458,7 +2452,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2475,7 +2468,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2492,7 +2484,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2509,7 +2500,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2526,7 +2516,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2543,7 +2532,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2560,7 +2548,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2583,7 +2570,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2606,7 +2592,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2629,7 +2614,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2652,7 +2636,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2675,7 +2658,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2698,7 +2680,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2721,7 +2702,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2741,7 +2721,6 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -2764,7 +2743,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2784,7 +2762,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2804,7 +2781,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14183,7 +14159,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 4b41d78..31a7302 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "pg": "^8.13.1", "pino": "^9.5.0", "qrcode": "^1.5.4", + "sharp": "^0.34.5", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/server/api/messages/[instanceId]/[chatId]/send-media.post.ts b/server/api/messages/[instanceId]/[chatId]/send-media.post.ts deleted file mode 100644 index 61f490d..0000000 --- a/server/api/messages/[instanceId]/[chatId]/send-media.post.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * 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 - } - } - - // Build options with quoted message if exists - const options: any = {} - if (quotedMessage) { - options.quoted = quotedMessage - } - - // Send message - console.log(`[SendMedia] Sending ${mediaType} to ${jid}`) - const result = await socket.sendMessage(jid, content, options) - - 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] - ) -} diff --git a/server/api/messages/[instanceId]/[chatId]/send.post.ts b/server/api/messages/[instanceId]/[chatId]/send.post.ts index 5b0c7bb..8d63f72 100644 --- a/server/api/messages/[instanceId]/[chatId]/send.post.ts +++ b/server/api/messages/[instanceId]/[chatId]/send.post.ts @@ -1,11 +1,33 @@ /** * POST /api/messages/:instanceId/:chatId/send - * Send a message to a chat (from UI) + * 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' -interface SendMessageBody { +// 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 @@ -19,16 +41,13 @@ export default defineEventHandler(async (event) => { const instanceId = getRouterParam(event, 'instanceId') const chatId = getRouterParam(event, 'chatId') - const body = await readBody(event) - // Accept both 'content' and 'message' fields - const messageText = body.content || body.message - if (!messageText?.trim()) { - throw createError({ statusCode: 400, message: 'Message content is required' }) + if (!instanceId || !chatId) { + throw createError({ statusCode: 400, message: 'Missing instanceId or chatId' }) } // Get chat JID - const chatResult = await query<{ jid: string }>( + const chatResult = await query( 'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2', [chatId, instanceId] ) @@ -39,6 +58,40 @@ export default defineEventHandler(async (event) => { 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(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) { @@ -52,16 +105,15 @@ export default defineEventHandler(async (event) => { } try { - // Build message content and options - const content: any = { text: messageText } + const content = { text: messageText } const options: any = {} if (quotedMessage) { options.quoted = quotedMessage } - const result = await baileysManager.sendMessage(instanceId!, jid, content, options) + const result = await baileysManager.sendMessage(instanceId, jid, content, options) - // Save sent message to database + // Save to database await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, @@ -82,20 +134,243 @@ export default defineEventHandler(async (event) => { ] ) - // Update chat last message time await query( - `UPDATE chats SET last_message_at = NOW() WHERE id = $1`, + `UPDATE chats SET last_message_at = NOW(), last_message_type = 'text' WHERE id = $1`, [chatId] ) return { success: true, - messageId: result.key.id + 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] + ) +} diff --git a/server/services/media/sticker-processor.ts b/server/services/media/sticker-processor.ts new file mode 100644 index 0000000..a338a36 --- /dev/null +++ b/server/services/media/sticker-processor.ts @@ -0,0 +1,95 @@ +/** + * Sticker Processor Service + * Converts images to WhatsApp-compatible stickers (512x512 WebP) + */ +import sharp from 'sharp' + +// WhatsApp sticker requirements +const STICKER_SIZE = 512 +const MAX_STICKER_KB = 100 // WhatsApp limit for static stickers + +export interface StickerResult { + buffer: Buffer + mimetype: string +} + +/** + * Convert an image buffer to a WhatsApp-compatible sticker + * Requirements: + * - WebP format + * - 512x512 pixels (will resize maintaining aspect ratio and add padding) + * - Max 100KB for static stickers + */ +export async function convertToSticker( + inputBuffer: Buffer, + inputMimetype: string +): Promise { + // Validate input is an image + if (!inputMimetype.startsWith('image/')) { + throw new Error('Input must be an image') + } + + // Process with sharp - resize to fit 512x512 with transparent padding + let quality = 100 + let buffer = await sharp(inputBuffer) + .resize(STICKER_SIZE, STICKER_SIZE, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background + }) + .webp({ quality, lossless: false }) + .toBuffer() + + // If too large, reduce quality iteratively + while (buffer.length > MAX_STICKER_KB * 1024 && quality > 10) { + quality -= 10 + buffer = await sharp(inputBuffer) + .resize(STICKER_SIZE, STICKER_SIZE, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } + }) + .webp({ quality, lossless: false }) + .toBuffer() + } + + // If still too large after quality reduction, try resizing smaller + if (buffer.length > MAX_STICKER_KB * 1024) { + let size = STICKER_SIZE + while (buffer.length > MAX_STICKER_KB * 1024 && size > 128) { + size -= 64 + buffer = await sharp(inputBuffer) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } + }) + .webp({ quality: 80, lossless: false }) + .toBuffer() + } + // Resize back to 512x512 for WhatsApp compatibility + if (size < STICKER_SIZE) { + buffer = await sharp(buffer) + .resize(STICKER_SIZE, STICKER_SIZE, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } + }) + .toBuffer() + } + } + + return { + buffer, + mimetype: 'image/webp' + } +} + +/** + * Check if an image can be converted to a sticker + */ +export function canConvertToSticker(mimetype: string): boolean { + const supportedTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif' // Will take first frame + ] + return supportedTypes.includes(mimetype) +}