Unificar endpoint de envío y agregar soporte para stickers
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m28s
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
This commit is contained in:
@@ -7,15 +7,27 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="relative group"
|
class="relative group"
|
||||||
>
|
>
|
||||||
<!-- Image preview -->
|
<!-- Image preview with sticker toggle -->
|
||||||
<div
|
<div
|
||||||
v-if="isImage(file)"
|
v-if="isImage(file)"
|
||||||
class="relative"
|
class="relative"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="getPreviewUrl(file)"
|
:src="getPreviewUrl(file)"
|
||||||
class="h-20 w-20 object-cover rounded-lg"
|
class="h-20 w-20 object-cover rounded-lg transition-all"
|
||||||
|
:class="{ 'ring-2 ring-[var(--wa-green)]': stickerModes[index] }"
|
||||||
/>
|
/>
|
||||||
|
<!-- Sticker toggle button -->
|
||||||
|
<button
|
||||||
|
class="absolute bottom-0 left-0 right-0 py-0.5 text-[10px] font-medium transition-colors"
|
||||||
|
:class="stickerModes[index]
|
||||||
|
? 'bg-[var(--wa-green)] text-white rounded-b-lg'
|
||||||
|
: 'bg-black/60 text-white/80 rounded-b-lg hover:bg-black/80'"
|
||||||
|
@click="toggleStickerMode(index)"
|
||||||
|
>
|
||||||
|
{{ stickerModes[index] ? 'Sticker' : 'Imagen' }}
|
||||||
|
</button>
|
||||||
|
<!-- Remove button -->
|
||||||
<button
|
<button
|
||||||
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
@click="$emit('remove', index)"
|
@click="$emit('remove', index)"
|
||||||
@@ -81,16 +93,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Caption input -->
|
<!-- Caption input (hide if all files are stickers) -->
|
||||||
<div v-if="showCaption && files.length > 0">
|
<div v-if="showCaption && files.length > 0 && !allStickers">
|
||||||
<UInput
|
<UInput
|
||||||
:model-value="caption"
|
:model-value="caption"
|
||||||
placeholder="Agregar descripción..."
|
placeholder="Agregar descripcion..."
|
||||||
size="sm"
|
size="sm"
|
||||||
class="bg-[var(--wa-bg-light)]"
|
class="bg-[var(--wa-bg-light)]"
|
||||||
@update:model-value="$emit('update:caption', $event)"
|
@update:model-value="$emit('update:caption', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Info text for stickers -->
|
||||||
|
<p v-if="hasStickers" class="text-xs text-[var(--wa-text-muted)]">
|
||||||
|
Los stickers se convertiran a formato 512x512 WebP
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -101,16 +118,45 @@ interface Props {
|
|||||||
showCaption?: boolean
|
showCaption?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
caption: '',
|
caption: '',
|
||||||
showCaption: true
|
showCaption: true
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
remove: [index: number]
|
remove: [index: number]
|
||||||
'update:caption': [value: string]
|
'update:caption': [value: string]
|
||||||
|
'update:stickerModes': [modes: boolean[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Track which images should be sent as stickers
|
||||||
|
const stickerModes = ref<boolean[]>([])
|
||||||
|
|
||||||
|
// 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
|
// Preview URL cache
|
||||||
const previewUrls = new Map<File, string>()
|
const previewUrls = new Map<File, string>()
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
:caption="caption"
|
:caption="caption"
|
||||||
@remove="removeFile"
|
@remove="removeFile"
|
||||||
@update:caption="caption = $event"
|
@update:caption="caption = $event"
|
||||||
|
@update:stickerModes="stickerModes = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Recording indicator -->
|
<!-- Recording indicator -->
|
||||||
@@ -163,7 +164,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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]
|
sendVoice: [audioFile: File]
|
||||||
cancelReply: []
|
cancelReply: []
|
||||||
typing: []
|
typing: []
|
||||||
@@ -181,6 +182,7 @@ const message = ref('')
|
|||||||
const selectedFiles = ref<File[]>([])
|
const selectedFiles = ref<File[]>([])
|
||||||
const caption = ref('')
|
const caption = ref('')
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
const stickerModes = ref<boolean[]>([])
|
||||||
|
|
||||||
// Audio recorder
|
// Audio recorder
|
||||||
const {
|
const {
|
||||||
@@ -258,13 +260,15 @@ const handleSend = () => {
|
|||||||
message.value.trim(),
|
message.value.trim(),
|
||||||
[...selectedFiles.value],
|
[...selectedFiles.value],
|
||||||
caption.value,
|
caption.value,
|
||||||
props.replyingTo?.messageId
|
props.replyingTo?.messageId,
|
||||||
|
[...stickerModes.value]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clear state
|
// Clear state
|
||||||
message.value = ''
|
message.value = ''
|
||||||
selectedFiles.value = []
|
selectedFiles.value = []
|
||||||
caption.value = ''
|
caption.value = ''
|
||||||
|
stickerModes.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
|
|||||||
@@ -496,14 +496,15 @@ const copyToClipboard = async (text: string) => {
|
|||||||
// Reply state (will be used for quote functionality)
|
// Reply state (will be used for quote functionality)
|
||||||
const replyingTo = ref<any>(null)
|
const replyingTo = ref<any>(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
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const instanceId = selectedInstance.value.value
|
const instanceId = selectedInstance.value.value
|
||||||
const chatId = selectedChat.value.id
|
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) {
|
if (files.length > 0) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
||||||
@@ -522,13 +523,18 @@ const handleSendMessage = async (content: string, files: File[], caption: string
|
|||||||
formData.append('quotedMessageId', quotedId || replyingTo.value.messageId)
|
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',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
} else if (content) {
|
} else if (content) {
|
||||||
// Send text message
|
// Send text message
|
||||||
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
await $fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
content,
|
content,
|
||||||
@@ -559,7 +565,7 @@ const handleSendVoice = async (audioFile: File) => {
|
|||||||
formData.append('files', audioFile)
|
formData.append('files', audioFile)
|
||||||
formData.append('isPtt', 'true') // Mark as push-to-talk (voice note)
|
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',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
|
|||||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
@@ -2327,7 +2328,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2344,7 +2344,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2367,7 +2366,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2390,7 +2388,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2407,7 +2404,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2424,7 +2420,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2441,7 +2436,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2458,7 +2452,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2475,7 +2468,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2492,7 +2484,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2509,7 +2500,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2526,7 +2516,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2543,7 +2532,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2560,7 +2548,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2583,7 +2570,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2606,7 +2592,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2629,7 +2614,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2652,7 +2636,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2675,7 +2658,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2698,7 +2680,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2721,7 +2702,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"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",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.7.0"
|
"@emnapi/runtime": "^1.7.0"
|
||||||
},
|
},
|
||||||
@@ -2764,7 +2743,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2784,7 +2762,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2804,7 +2781,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14183,7 +14159,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/messages/:instanceId/:chatId/send
|
* 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 { query } from '../../../../utils/database'
|
||||||
import { baileysManager } from '../../../../services/baileys/manager'
|
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
|
content?: string
|
||||||
message?: string
|
message?: string
|
||||||
quotedMessageId?: string
|
quotedMessageId?: string
|
||||||
@@ -19,16 +41,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const instanceId = getRouterParam(event, 'instanceId')
|
const instanceId = getRouterParam(event, 'instanceId')
|
||||||
const chatId = getRouterParam(event, 'chatId')
|
const chatId = getRouterParam(event, 'chatId')
|
||||||
const body = await readBody<SendMessageBody>(event)
|
|
||||||
|
|
||||||
// Accept both 'content' and 'message' fields
|
if (!instanceId || !chatId) {
|
||||||
const messageText = body.content || body.message
|
throw createError({ statusCode: 400, message: 'Missing instanceId or chatId' })
|
||||||
if (!messageText?.trim()) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Message content is required' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get chat JID
|
// Get chat JID
|
||||||
const chatResult = await query<{ jid: string }>(
|
const chatResult = await query(
|
||||||
'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2',
|
'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2',
|
||||||
[chatId, instanceId]
|
[chatId, instanceId]
|
||||||
)
|
)
|
||||||
@@ -39,6 +58,40 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const jid = chatResult.rows[0].jid
|
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
|
// Get quoted message if provided
|
||||||
let quotedMessage = null
|
let quotedMessage = null
|
||||||
if (body.quotedMessageId) {
|
if (body.quotedMessageId) {
|
||||||
@@ -52,16 +105,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build message content and options
|
const content = { text: messageText }
|
||||||
const content: any = { text: messageText }
|
|
||||||
const options: any = {}
|
const options: any = {}
|
||||||
if (quotedMessage) {
|
if (quotedMessage) {
|
||||||
options.quoted = 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(
|
await query(
|
||||||
`INSERT INTO messages (
|
`INSERT INTO messages (
|
||||||
instance_id, chat_id, message_id, from_jid, from_me,
|
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(
|
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]
|
[chatId]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
messageId: result.key.id
|
messages: [{ messageId: result.key.id, type: 'text' }]
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Send] Error sending text:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: `Failed to send message: ${(error as Error).message}`
|
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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
95
server/services/media/sticker-processor.ts
Normal file
95
server/services/media/sticker-processor.ts
Normal file
@@ -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<StickerResult> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user