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)
+}